Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.second.so/llms.txt

Use this file to discover all available pages before exploring further.

Integrations connect app agents to external services such as Linear, HubSpot, Slack, Gmail, Calendar, and other APIs without exposing credentials to the agent runtime. Second supports two credential shapes:
  • an integration grant records what one app asked to use, including provider domain, key slug, auth mode, permission groups, setup steps, app, and requester metadata
  • a static credential stores app-scoped API keys, bot tokens, or private app tokens for static-secret grants
  • an OAuth provider config stores one workspace/provider OAuth client, such as a customer-owned Google OAuth app
  • a connected account stores one user’s OAuth connection metadata and token secret references for one provider config
A configured credential for one app never silently powers another app. If Roadmap Tracker asks for Linear read access and Sprint Writer asks for Linear write access, those are separate grants and separate setup decisions even though both use linear.app. OAuth adds a second boundary: a provider client configured by an admin does not grant access to any user’s data by itself. Each user connects their own account, and runtime resolves the account from the server-created app-agent run record.

How it works

Static-secret custom tools:
Agent calls custom tool
  → Worker MCP tool handler
    → POST /api/internal/tool-execute
      → Verify tool appears in approved agents.json payload
      → Resolve grant by workspaceId + appId + domain + keySlug
      → Follow that grant's credential binding
      → Read named secrets from Vault or local development storage
      → Inject named secrets and tool input into endpoint templates
      → Validate hostname, protocol, and resolved IPs
      → Execute HTTP request to external API
      → Return response to agent
If the current app grant is missing, unconnected, not configured, or missing a required secret, the endpoint returns a random entry from the tool’s mockData instead of using another app’s credential. OAuth custom tools use the same approved agents.json boundary, but the credential lookup is per triggering user:
Agent calls OAuth custom tool
  → Worker posts toolName, approved toolSpec, toolInput, runId
    → POST /api/internal/tool-execute
      → Verify tool appears in approved agents.json payload
      → Resolve grant by workspaceId + appId + domain + keySlug
      → Verify grant auth metadata matches the approved tool
      → Load app_agent_runs by workspaceId + appId + runId
      → Resolve triggering user from the run record
      → Load connected_account by workspaceId + userId + providerConfigId
      → Check scopes and revoked state
      → Refresh access token on demand if missing or near expiry
      → Inject Authorization: Bearer <access token> server-side
      → Validate hostname, protocol, and resolved IPs
      → Execute HTTP request to external API
      → Return bounded response to agent
There is no separate OAuth refresh service, cron, sidecar, or Kubernetes job. Refresh happens synchronously inside the existing Next.js API path when a tool needs an access token. The “refresh server” is the provider token endpoint.

Data model

integrations stores app grants:
type IntegrationDocument = {
  _id: string;
  workspaceId: string;
  appId: string;
  appName: string;
  name: string;              // "Linear"
  domain: string;            // "linear.app"
  keySlug: string;           // "default", "write-access", etc.
  keyName: string;           // "Linear read key for Roadmap Tracker"
  capabilityLabel: string;   // "Linear read"
  auth:
    | { type: "static_secret" }
    | {
        type: "oauth2";
        providerKey: string;
        identity: "triggering_user";
        authorizationUrl: string;
        tokenUrl: string;
        scopes: string[];
        tokenAuthMethod: "client_secret_post" | "client_secret_basic" | "none";
        authorizationParams?: Record<string, string>;
        tokenParams?: Record<string, string>;
      };
  accessLevel: "read" | "write" | "delete_admin" | "mixed" | "unknown";
  credentialBinding: { mode: "none" } | { mode: "dedicated"; credentialId: string };
  permissionGroups: IntegrationPermissionGroup[];
  secretRequirements: IntegrationSecretRequirement[];
  setupInstructions: IntegrationSetupInstructions | null;
  requestedByUserId: string;
  requestedByUserName: string;
  requestedAt: Date;
  createdAt: Date;
  updatedAt: Date;
};
integration_credentials stores secret material and configured snapshots:
type IntegrationCredentialDocument = {
  _id: string;
  workspaceId: string;
  domain: string;
  credentialName: string;
  configured: boolean;
  vaultSecretIds: Record<string, string>;
  localSecrets: Record<string, string>;
  configuredPermissionGroups: IntegrationPermissionGroup[];
  configuredSecrets: string[];
  capabilityFingerprint: string;
  linkedGrantIds: string[];
  createdAt: Date;
  updatedAt: Date;
};
Read models expose configured secret names, never secret values or Vault IDs. OAuth provider configs store workspace/provider OAuth client metadata:
type OAuthProviderConfigDocument = {
  _id: string;
  workspaceId: string;
  providerKey: string;              // "google", "microsoft", "zoom", etc.
  displayName: string;
  authorizationUrl: string;
  tokenUrl: string;
  tokenAuthMethod: "client_secret_post" | "client_secret_basic" | "none";
  defaultAuthorizationParams?: Record<string, string>;
  defaultTokenParams?: Record<string, string>;
  clientId: string | null;
  clientSecretRef: string | null;   // WorkOS Vault or local encrypted ref
  configured: boolean;
  configuredAt?: Date | null;
  createdAt: Date;
  updatedAt: Date;
};
Connected accounts store per-user OAuth state:
type ConnectedAccountDocument = {
  _id: string;
  workspaceId: string;
  userId: string;
  providerConfigId: string;
  providerKey: string;
  source: "customer_oauth" | "local_direct" | "hosted_broker";
  externalSubject?: string | null;
  accountEmail?: string | null;
  accountName?: string | null;
  grantedScopes: string[];
  refreshTokenRef?: string | null;  // WorkOS Vault or local encrypted ref
  accessTokenRef?: string | null;   // optional short-lived cache
  accessTokenExpiresAt?: Date | null;
  lastRefreshAt?: Date | null;
  lastRefreshError?: string | null;
  revokedAt?: Date | null;
  createdAt: Date;
  updatedAt: Date;
};
Provider keys are workspace-local grouping keys, not a hardcoded registry. The builder discovers official OAuth URLs and scopes from provider docs and writes them into agents.json and integration-setup.json; runtime safety comes from admin approval plus URL/scope invariants.

integration-setup.json

The builder creates integration-setup.json only when this app needs setup. The same provider can appear in many apps because the grant identity includes appId and keySlug.
{
  "integrations": [
    {
      "name": "Slack",
      "domain": "slack.com",
      "keySlug": "default",
      "keyName": "Slack post key for this app",
      "capabilityLabel": "Slack post",
      "why": "This app sends Slack messages.",
      "permissionGroups": [
        {
          "name": "Write",
          "description": "Allows the app to post messages into selected Slack channels.",
          "permissions": ["chat:write"]
        }
      ],
      "secrets": [
        {
          "name": "SLACK_BOT_TOKEN",
          "label": "Slack bot token",
          "description": "Paste the Bot User OAuth Token that starts with xoxb-.",
          "required": true
        }
      ],
      "setupInstructions": {
        "overview": "Create or update a Slack app, grant the bot scope, install it to the workspace, and paste the bot token in Second.",
        "steps": [
          {
            "title": "Open Slack apps",
            "description": "Go to [Slack | API apps](https://api.slack.com/apps) and create a new app or open the existing app you want Second to use.",
            "url": "https://api.slack.com/apps"
          }
        ]
      }
    }
  ]
}
When the builder calls present_integration_setup, the worker reads this file and posts it to /api/internal/integration-requirements. That sync is idempotent for the current app: listed grants are upserted, and grants no longer present for the app are removed. OAuth setup items use the same outer shape but declare auth.type = "oauth2" instead of static secrets:
{
  "integrations": [
    {
      "name": "Google Gmail",
      "domain": "googleapis.com",
      "keySlug": "gmail-read",
      "keyName": "Google OAuth client for this app",
      "capabilityLabel": "Gmail metadata search",
      "why": "This app searches the triggering user's Gmail metadata.",
      "auth": {
        "type": "oauth2",
        "providerKey": "google",
        "identity": "triggering_user",
        "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth",
        "tokenUrl": "https://oauth2.googleapis.com/token",
        "scopes": ["https://www.googleapis.com/auth/gmail.metadata"],
        "tokenAuthMethod": "client_secret_post",
        "authorizationParams": {
          "access_type": "offline",
          "prompt": "consent"
        }
      },
      "permissionGroups": [
        {
          "name": "Read-only",
          "description": "Allows the app to search Gmail metadata for the connected user.",
          "permissions": ["https://www.googleapis.com/auth/gmail.metadata"]
        }
      ],
      "setupInstructions": {
        "overview": "Create a customer-owned OAuth app, add Second's redirect URI, configure the client ID/secret in Second, then each user connects their own account.",
        "steps": [
          {
            "title": "Create OAuth client",
            "description": "Create a provider OAuth app with the listed scopes. For Google Workspace enterprise deployments, use an Internal app when appropriate."
          },
          {
            "title": "Add redirect URI",
            "description": "Copy the redirect URI shown in Second's integration settings into the provider OAuth app."
          },
          {
            "title": "Configure and connect",
            "description": "Paste the client ID and client secret into Second, then connect your own account."
          }
        ]
      }
    }
  ]
}
For local OAuth smoke tests, run the dev server without portless:
SECOND_DEV_PORTLESS=0 PORT=4198 npm run dev
Use the resulting http://localhost:<port> app URL and register the exact redirect URI shown in Second, such as http://localhost:4198/api/oauth/callback. Portless *.second.localhost URLs are useful for normal development, but providers such as Google do not treat them as loopback OAuth redirect URIs. The packaged npx --yes @second-inc/cli local runtime should follow the same plain loopback shape for OAuth-capable local runs; portless is only a npm run dev convenience.

agents.json custom tools

Custom tools reference the same app key with integration.keySlug. If omitted, Second normalizes the slug to "default".
{
  "type": "custom",
  "name": "linear_search_issues",
  "integration": {
    "name": "Linear",
    "domain": "linear.app",
    "keySlug": "default"
  },
  "endpoint": {
    "method": "POST",
    "url": "https://api.linear.app/graphql",
    "headers": {
      "Authorization": "{{secrets.LINEAR_API_KEY}}"
    }
  },
  "mockData": [{ "issues": [] }]
}
present_agents validates that custom tools include integration metadata, endpoint method, and endpoint URL. Static-secret tools include a named {{secrets.NAME}} placeholder, OAuth tools include auth metadata, and public unauthenticated tools may omit both when the provider’s official API requires no credentials. Draft and published runtime calls then verify the requested tool still appears in the approved agents.json payload before credentials are injected or, for public tools, before the bounded public request is executed. OAuth custom tools are still custom HTTP tools. They declare integration.auth and omit Authorization headers:
{
  "type": "custom",
  "name": "gmail_search_messages",
  "integration": {
    "name": "Google Gmail",
    "domain": "googleapis.com",
    "keySlug": "gmail-read",
    "auth": {
      "type": "oauth2",
      "providerKey": "google",
      "identity": "triggering_user",
      "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth",
      "tokenUrl": "https://oauth2.googleapis.com/token",
      "scopes": ["https://www.googleapis.com/auth/gmail.metadata"],
      "tokenAuthMethod": "client_secret_post",
      "authorizationParams": {
        "access_type": "offline",
        "prompt": "consent"
      }
    }
  },
  "endpoint": {
    "method": "GET",
    "url": "https://gmail.googleapis.com/gmail/v1/users/me/messages",
    "queryParams": {
      "q": "{{query}}",
      "maxResults": "10"
    }
  },
  "mockData": [{ "messages": [] }]
}
OAuth tools must not include {{oauth.access_token}}, {{access_token}}, {{token}}, {{secrets.*}}, or an explicit Authorization header. The broker injects the bearer token after resolving the triggering user from runId.

Setup state

A grant needs setup when:
  • no credential is bound to the current app grant
  • the bound credential is not configured
  • a requested permission/scope is not present in configured snapshots
  • a requested required secret name is not present in configured snapshots
  • an OAuth provider config shell exists but has no client ID/secret
  • the current user has not connected the required OAuth account
  • the connected OAuth account is revoked or missing required scopes
Review approval and direct publish check only the current app’s grants. A different app’s configured provider key does not clear the gate.

Secret management

ModeStorageWhen
WorkOS VaultvaultSecretIds[name] in integration_credentialsProduction deployments with WorkOS configured
Local secretlocalSecrets[name] in integration_credentialsLocal development without WorkOS
OAuth secret storevault:<id> or local:v1:<ciphertext> refs in provider/account rowsOAuth client secrets, refresh tokens, and optional short-lived access-token cache
Secrets are injected by replacing named placeholders such as {{secrets.SLACK_BOT_TOKEN}} in URL, headers, query params, and request body. Tool input fields can also be used as placeholders, such as {{query}}. OAuth client secrets and refresh tokens use apps/web/src/lib/oauth/secret-store.ts. When WorkOS Vault is configured, values go to Vault. In local development, the adapter encrypts values with SECOND_TOKEN_ENCRYPTION_KEY or a generated gitignored key under .second-dev/. In production without WorkOS Vault, the local adapter fails closed unless SECOND_TOKEN_ENCRYPTION_KEY is configured. If the agent provides tool input but the endpoint spec does not use any non-secret placeholders, execution fails. This prevents custom tools from accidentally turning a lookup into a broad static API call.

Tool execution constraints

  • HTTPS only, except localhost during development
  • final URL hostname must match integration.domain or one of its subdomains
  • requested tool must be present in the approved app agents.json payload
  • runtime grant lookup includes workspaceId, appId, domain, and keySlug
  • OAuth runtime additionally requires runId, loads the run by workspaceId + appId + runId, and resolves the triggering user from that server-created row
  • OAuth provider config and connected account lookups include workspaceId, and connected account lookup also includes userId
  • OAuth authorization and token URLs must be HTTPS and resolve outside private network ranges before Second redirects or exchanges tokens
  • external requests to private/internal IP ranges are rejected
  • 30-second external request timeout
  • 1MB response limit
  • mock data is returned for missing or unconfigured integrations, including OAuth missing-account, revoked-account, missing-scope, or provider-config cases

API routes

MethodPathPurpose
GET/api/workspaces/[wId]/integrationsProjected settings read model grouped by app/key. No secret values or Vault IDs
POST/api/workspaces/[wId]/integrationsRejected for app-blind creates; grants come from app setup sync
PATCH/api/workspaces/[wId]/integrations/[id]Configure or rotate the credential for one app grant
POST/api/workspaces/[wId]/integrations/[id]Reset saved credential state for one app grant
DELETE/api/workspaces/[wId]/integrations/[id]Delete one app grant and its dedicated credential
POST/api/internal/integration-requirementsSync app-scoped grant requirements from integration-setup.json
POST/api/internal/workspace-integrationsReturn current app grant metadata to the builder, without secret values
POST/api/internal/tool-executeExecute a custom HTTP tool with app-grant credential resolution
PATCH/api/workspaces/[wId]/oauth-provider-configs/[providerConfigId]Configure or rotate a workspace OAuth client
GET/api/workspaces/[wId]/oauth/[providerConfigId]/startStart current-user OAuth consent for one app grant
GET/api/oauth/callbackGeneric OAuth callback that exchanges code, stores tokens, and redirects back
DELETE/api/workspaces/[wId]/connected-accounts/[accountId]Revoke the current user’s connected account
Workspace realtime publishes compact integration.changed invalidation events after successful mutations. Events may include IDs and key slug metadata, but never secrets, prompts, source files, headers, cookies, or full documents.

Key files

FileRole
apps/web/src/lib/db/types.tsGrant and credential document types
apps/web/src/lib/db/repositories/integrations.tsGrant sync, credential configure/reset/delete, setup checks
apps/web/src/lib/db/repositories/oauth-provider-configs.tsOAuth provider config shell/configure helpers
apps/web/src/lib/db/repositories/connected-accounts.tsConnected-account lookup, scope checks, token cache, revoke state
apps/web/src/lib/oauth/secret-store.tsWorkOS/local encrypted OAuth secret references
apps/web/src/lib/oauth/token-exchange.tsCode exchange and refresh-token request helper
apps/web/src/lib/oauth/token-broker.tsOn-demand access-token cache/refresh broker
apps/web/src/app/api/internal/tool-execute/route.tsRuntime secret injection, approval check, domain/IP guards
apps/web/src/app/api/internal/integration-requirements/route.tsWorker-to-web setup sync
apps/web/src/app/api/workspaces/[wId]/integrations/[id]/route.tsConfigure, reset, and delete one app grant
apps/web/src/app/api/workspaces/[wId]/oauth/[providerConfigId]/start/route.tsOAuth consent start
apps/web/src/app/api/oauth/callback/route.tsOAuth callback
apps/web/src/app/w/[wId]/settings/integrations/integrations-client.tsxApp/key settings UI
apps/worker/src/runner.tsBuilder integration tools and custom app tool bridge