Integrations

Custom Providers

Ship your own provider as an AFPS package or create it via the REST API when the service you need isn't in the 60 built-in providers.

When the service you need isn't in the built-in providers, author your own. Custom providers use the exact same format, sidecar protocol, and connection flow as built-in ones: manifest shape, @scope/name addressing, credential injection, authorized URIs, everything. Nothing is second-class.

Two creation paths, pick based on how you want to ship it:

PathWhen to pick itVersion historyPortable file
POST /api/providersOne-off, internal service, scaffolded by a coding agentAuto-snapshots to 1.0.0 (or the version you pass); subsequent PUT mutates the draftNo, lives only in this org's DB
AFPS package importPortable, semver, multi-env, publishableFull semver + SHA256 integrity, bumps create new version rowsYes, .afps ZIP you can ship between instances

Path 1: REST API (flat payload)

The fastest path when you want to declare a provider from code without packaging it. Send a flat JSON body; the server builds the nested definition internally and stores a draft manifest. Good shape for UI forms and one-off internal providers.

appstrate api POST /api/providers \
  -H 'Content-Type: application/json' \
  -d '{
    "id": "@acme/my-api",
    "displayName": "Acme Internal API",
    "authMode": "api_key",
    "credentialHeaderName": "Authorization",
    "credentialHeaderPrefix": "Bearer",
    "authorizedUris": ["https://api.acme.com/*"]
  }'

Rules:

  • Permission required: providers:write (admin or owner).
  • id must start with @ and the scope must match your org's scope.
  • You cannot shadow a built-in: posting @appstrate/gmail returns 403 system_entity_forbidden.

Full list of accepted fields per authMode (more than 20 optional fields across OAuth2 / OAuth1 / api_key / basic / custom): API reference for createProvider and related endpoints (update, configureCredentials, delete).

Path 2: AFPS package import

The path built-in providers use themselves. Write a manifest.json with the proper nested shape, ZIP it, import via POST /api/packages/import. You get semver versions, SHA256 integrity, dist-tags, forkability, and a portable .afps file you can publish to any Appstrate instance.

The nested manifest shape

This is the shape actually stored in the database (whether you arrived via Path 1 or Path 2). Unlike Path 1's flat payload, the AFPS manifest nests the auth-specific fields under definition.oauth2, definition.oauth1, definition.credentials. These examples are copied from the real .afps files shipped in system-packages/.

OAuth 2.0 (from the Slack system provider):

{
  "$schema": "https://afps.appstrate.dev/schema/v1/provider.schema.json",
  "name": "@acme/slack-fork",
  "version": "1.0.0",
  "type": "provider",
  "schemaVersion": "1.0",
  "displayName": "Slack (Acme fork)",
  "description": "Team messaging",
  "iconUrl": "slack",
  "categories": ["messaging", "productivity"],
  "docsUrl": "https://api.slack.com/docs",
  "definition": {
    "authMode": "oauth2",
    "oauth2": {
      "authorizationUrl": "https://slack.com/oauth/v2/authorize",
      "tokenUrl": "https://slack.com/api/oauth.v2.access",
      "defaultScopes": ["channels:read", "chat:write"],
      "scopeSeparator": ",",
      "pkceEnabled": false,
      "tokenAuthMethod": "client_secret_post"
    },
    "credentialHeaderName": "Authorization",
    "credentialHeaderPrefix": "Bearer",
    "authorizedUris": ["https://slack.com/api/*"],
    "availableScopes": [
      { "value": "channels:read", "label": "View channels" },
      { "value": "chat:write", "label": "Send messages" }
    ]
  },
  "setupGuide": {
    "callbackUrlHint": "Set the redirect URL to: {{callbackUrl}}",
    "steps": [
      { "label": "Create a Slack app", "url": "https://api.slack.com/apps" },
      { "label": "Configure OAuth & Permissions" },
      { "label": "Copy your credentials" }
    ]
  }
}

Key point: authorizationUrl, tokenUrl, pkceEnabled, etc. live under definition.oauth2.*, NOT flat on definition. Getting this wrong is the single most common error when authoring custom OAuth providers by hand.

API key (from the Firecrawl system provider):

{
  "$schema": "https://afps.appstrate.dev/schema/v1/provider.schema.json",
  "name": "@acme/my-api",
  "version": "1.0.0",
  "type": "provider",
  "schemaVersion": "1.0",
  "displayName": "Acme Internal API",
  "description": "Internal data API",
  "iconUrl": "https://acme.com/icon.png",
  "categories": ["internal"],
  "docsUrl": "https://docs.acme.com",
  "definition": {
    "authMode": "api_key",
    "credentials": {
      "schema": {
        "type": "object",
        "properties": {
          "api_key": {
            "type": "string",
            "description": "Acme API key (starts with acme_)"
          }
        },
        "required": ["api_key"]
      },
      "fieldName": "api_key"
    },
    "credentialHeaderName": "Authorization",
    "credentialHeaderPrefix": "Bearer",
    "authorizedUris": ["https://api.acme.com/*"]
  }
}

The definition.credentials.schema is a JSON Schema that drives the connection form in the webapp. definition.credentials.fieldName tells the sidecar which property to pull out when substituting {{variable}} placeholders in outgoing headers.

OAuth 1.0a (rare, Trello is the only built-in that uses it):

{
  "definition": {
    "authMode": "oauth1",
    "oauth1": {
      "requestTokenUrl": "https://trello.com/1/OAuthGetRequestToken",
      "accessTokenUrl": "https://trello.com/1/OAuthGetAccessToken",
      "authorizationUrl": "https://trello.com/1/OAuthAuthorizeToken",
      "authorizationParams": {
        "name": "Your App Name",
        "scope": "read,write",
        "expiration": "never"
      }
    },
    "authorizedUris": ["https://api.trello.com/*"]
  }
}

Appstrate handles the HMAC-SHA1 signing and the Authorization: OAuth ... header automatically (no credentialHeaderName needed). clientId / clientSecret on the connection map to consumer key / consumer secret.

Custom (multi-field credentials) for services that need more than a single key (site URL + user + app password, API key + account ID, etc.). This example mirrors the real @appstrate/wordpress manifest:

{
  "definition": {
    "authMode": "custom",
    "credentials": {
      "schema": {
        "type": "object",
        "properties": {
          "site_url": { "type": "string", "description": "Your site URL (e.g. https://mysite.com)" },
          "username": { "type": "string" },
          "application_password": { "type": "string", "description": "Generated in the target service" }
        },
        "required": ["site_url", "username", "application_password"]
      }
    },
    "allowAllUris": true,
    "authorizedUris": []
  }
}

When the target URL is tenant-specific (different domain per end-user), use allowAllUris: true with an empty authorizedUris list. authorizedUris patterns are matched as static strings; credential placeholders like {{site_url}} are substituted into the target URL, headers, and body at call time (via substituteVars), but NOT into the authorizedUris patterns themselves.

Pack and import

# From a directory containing manifest.json (at root, not nested)
bash scripts/afps-pack.sh /path/to/provider-dir /tmp/my-provider.afps
appstrate api POST /api/packages/import -F file=@/tmp/my-provider.afps

Manual alternative without the helper:

cd /path/to/provider-dir && zip -r /tmp/my-provider.afps manifest.json
appstrate api POST /api/packages/import -F file=@/tmp/my-provider.afps

On 409 DRAFT_OVERWRITE, bump the version (preferred) or add -q force=true to overwrite the draft.

Use the custom provider in an agent

Identical to a built-in provider: declare it in the agent's manifest by scoped name.

{
  "dependencies": {
    "providers": { "@acme/my-api": "^1.0.0" }
  },
  "providersConfiguration": {
    "@acme/my-api": {
      "scopes": ["read:items"],
      "connectionMode": "user"
    }
  }
}

End-users connect through the same UI flow (OAuth redirect or credential form, depending on authMode). The sidecar proxy injects credentials using the same protocol, so $SIDECAR_URL/proxy with X-Provider: @acme/my-api + X-Target: https://api.acme.com/... works out of the box.

OAuth client credentials (client_id + client_secret)

For OAuth providers (built-in or custom), an admin must configure the OAuth app credentials on the application before end-users can connect. Navigate to Settings → Application → Providers → Configure in the webapp, or call PUT /api/providers/credentials/@scope/name with { credentials: { clientId, clientSecret } }.

Full endpoint spec: configureProviderCredentials.

Key point: clientId / clientSecret live per-application, not per-provider-definition. This is what lets you ship one shared custom-provider package and have each tenant bring their own OAuth app.

Scope this provider automatically with the skill

If you run a coding agent with the Appstrate skill loaded, ask it to scaffold the manifest:

Using the Appstrate skill, create a custom OAuth2 provider for Acme API. Auth URL https://acme.com/oauth/authorize, token URL https://acme.com/oauth/token, scopes read:items and write:items, authorized URIs https://api.acme.com/*.

The agent knows the nested shape (it reads references/manifest-schema.md), fills the correct fields, packs, imports, and runs a quick test.

Troubleshooting

SymptomCauseFix
422 on import: definition.oauth2.authorizationUrl is requiredOAuth2 fields flat on definition instead of nested under definition.oauth2Move them under definition.oauth2.*. See the Slack example above.
401 at connection time on an OAuth providerNo OAuth client_id / client_secret configured on the applicationConfigure them via the webapp or POST /api/providers/:id/credentials.
Sidecar returns 403 {"error": "URL not authorized for provider..."}Outgoing URL doesn't match any pattern in authorizedUrisAdd the pattern (static string with * wildcard), or set allowAllUris: true + authorizedUris: [] if the target URL is tenant-specific (the WordPress / WooCommerce pattern).
403 system_entity_forbidden on POST /api/providersTrying to create a provider under @appstrate/*System scope is reserved. Use your own org scope (@acme/*, @my-company/*).
Connection form shows blank / no fieldsauthMode is api_key / basic / custom but credentials.schema is missing or emptyAdd a valid JSON Schema under definition.credentials.schema.
Headers work in curl but not from the agentMissing {{variable}} placeholder in the header templateThe sidecar does NOT auto-inject: you must write Authorization: Bearer {{api_key}} explicitly in the provider's credentialHeaderPrefix + header name, OR use the credentialSchema.fieldName to tell the sidecar which field to pick.

Reference

  • Built-in Providers: the 60 that ship with Appstrate.
  • AFPS provider schema: the authoritative JSON Schema for validation.
  • OpenAPI for providers: REST endpoints (create, update, configure credentials, delete).
  • Inspect a real .afps as reference: every system provider under system-packages/provider-<name>-*.afps in the Appstrate monorepo is a valid AFPS package with a correct manifest.

On this page