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:
| Path | When to pick it | Version history | Portable file |
|---|---|---|---|
POST /api/providers | One-off, internal service, scaffolded by a coding agent | Auto-snapshots to 1.0.0 (or the version you pass); subsequent PUT mutates the draft | No, lives only in this org's DB |
| AFPS package import | Portable, semver, multi-env, publishable | Full semver + SHA256 integrity, bumps create new version rows | Yes, .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). idmust start with@and the scope must match your org's scope.- You cannot shadow a built-in: posting
@appstrate/gmailreturns403 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.afpsManual 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.afpsOn 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 URLhttps://acme.com/oauth/token, scopesread:itemsandwrite:items, authorized URIshttps://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
| Symptom | Cause | Fix |
|---|---|---|
422 on import: definition.oauth2.authorizationUrl is required | OAuth2 fields flat on definition instead of nested under definition.oauth2 | Move them under definition.oauth2.*. See the Slack example above. |
401 at connection time on an OAuth provider | No OAuth client_id / client_secret configured on the application | Configure 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 authorizedUris | Add 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/providers | Trying to create a provider under @appstrate/* | System scope is reserved. Use your own org scope (@acme/*, @my-company/*). |
| Connection form shows blank / no fields | authMode is api_key / basic / custom but credentials.schema is missing or empty | Add a valid JSON Schema under definition.credentials.schema. |
| Headers work in curl but not from the agent | Missing {{variable}} placeholder in the header template | The 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
.afpsas reference: every system provider undersystem-packages/provider-<name>-*.afpsin the Appstrate monorepo is a valid AFPS package with a correct manifest.