Building

API reference

The REST endpoints for publishing and installing plugins, and the webhook address plugin channels receive on.

View as Markdown

Everything on this page is also a button in the console. Use the API to publish from a script or CI.

Base URL: https://services.agentparley.ai/platform

Authentication: Authorization: Bearer <token> — the same signed-in user token the console uses. Every request acts as that user on that user's account. Ids in requests and responses are strings.

Errors: a failed request returns a JSON body { "message": "…", "traceId": "…" }. 400 is a validation error (the message says what and where), 404 is a plugin, version or installation that is not yours or does not exist, 409 is a conflict, 503 means retry shortly.

Get a token

POST /auth/login
Content-Type: application/json

{ "email": "you@example.com", "password": "…" }

The response is { "token": "…", "expiresAt": "…", "refreshToken": "…", "refreshExpiresAt": "…", "user": { … }, "account": { … } }. An email belongs to exactly one account.

POST /auth/refresh { "refreshToken": "…" } returns a fresh { token, expiresAt, refreshToken, refreshExpiresAt }; the old refresh token is replaced, so store the new one.

Authoring

Create a plugin

POST /plugins
Content-Type: application/json

{ "slug": "weather", "displayName": "Weather", "description": "Forecasts for your agents." }

201 Created with the plugin, including its id. slug and displayName are required. The slug matches ^[a-z0-9][a-z0-9_-]{0,63}$, must be unused (409 otherwise), and may not start with agentparley- (reserved for official plugins) or mcp_. A new plugin is private.

List and read your plugins

GET /plugins
GET /plugins/{id}?includeFiles=true

GET /plugins takes an optional isPriced=true|false. The detail includes every version (semVer, status, sourceSha256), visibility, and — with includeFiles — the source text of the latest published version's files.

Upload a package (create or version in one call)

POST /plugins/upload
Content-Type: multipart/form-data

file=@weather.zip

The one call a script or CI needs. The zip's readme.md frontmatter names the plugin: name is required, slug is optional (default: the name lowercased, with runs of other characters turned into -, at most 64 characters), and version is <major>.<minor> (default 1.0). The platform creates the plugin if the slug is new, or versions the one you own, and picks the patch itself: changed content publishes the latest patch + 1, a raised major.minor publishes <major>.<minor>.0, unchanged content publishes nothing.

curl -X POST "$BASE/plugins/upload" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@weather.zip;type=application/zip"

200 with { "pluginId": "…", "slug": "weather", "semVer": "1.0.3", "outcome": "Created" }. outcome is Created, NewVersion or Unchanged (then semVer is null). 409 if the slug belongs to another account, or the readme's major.minor is lower than the latest published one. If a new plugin's package fails to build, the empty plugin is removed again. The plugin stays private until you list it in Browse (below).

Publish a version from a zip

POST /plugins/{id}/versions/zip
Content-Type: multipart/form-data

file=@weather.zip
semVer=1.0.0

The zip holds the package — readme.md is required — optionally inside one wrapper folder. Up to 256 MB. semVer is digits-only major.minor.patch; you choose it. Zips are the only way to publish binary files (companion packs, portraits, icon.png, the gallery/ folder).

curl -X POST "$BASE/plugins/$PLUGIN_ID/versions/zip" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@weather.zip;type=application/zip" \
  -F "semVer=1.0.1"
Response Meaning
201 published; the body is { id, semVer, status, commandCount, skillCount, skillFileCount, middlewareCount, lifecycleHookCount, toolCount, hasCode }
400 a validation error — the message says what and where
409 that version already exists (versions are immutable)
503 bundler busy — retry shortly

Publish a version from a file list

POST /plugins/{id}/versions
Content-Type: application/json

{
  "semVer": "1.0.1",
  "files": [
    { "path": "readme.md", "contentText": "---\nname: Weather\n---\n# Weather\n…" },
    { "path": "index.ts", "contentText": "globalThis.tools.get_forecast = { … };" }
  ]
}

Text files only, up to 64 MB of request body. 201 returns the same summary as the zip publish.

Save a draft

POST /plugins/{id}/draft
Content-Type: application/json

{ "files": [ … ] }

Builds and validates like a publish, without a version number. 201 returns the same summary as a publish. Installs on the Draft policy (your own account only) follow it.

An icon for a JSON publish or draft goes up separately, because the file list is text only:

PUT    /plugins/{id}/draft/icon      multipart/form-data, file=@icon.png
DELETE /plugins/{id}/draft/icon

The file is a PNG or SVG of at most 512 KB. PUT returns { "draftIconUrl": "…" }; the next draft save or publish carries it. A zip's own icon.svg or icon.png replaces it.

The gallery works the same way. Every call below is for the plugin's owner and returns the gallery as it now stands:

POST   /plugins/{id}/draft/gallery          multipart/form-data, file=@screenshot.png
PUT    /plugins/{id}/draft/gallery          { "items": [ { "name": "screenshot.png", "caption": "The forecast card" } ] }
DELETE /plugins/{id}/draft/gallery/{name}

POST adds one picture or video to the end (at most 12 files; pictures .png, .jpg/.jpeg, .webp up to 5 MB, videos .mp4/.webm up to 50 MB; a file that isn't really the type its extension says is refused). PUT sets the order and the captions (at most 200 characters): items must name exactly the files the gallery holds now, in the order to show them. Each response is { "items": [ { "name", "kind", "caption", "signedUrl" } ] }. GET /plugins/{id} returns the same list as draftGallery, and isGalleryChanged, which is true when it differs from the latest version's. The next draft save or publish carries the gallery. A zip with a gallery/ folder replaces it; a zip without one keeps it.

List in Browse / remove from Browse

POST   /plugins/{id}/listing
DELETE /plugins/{id}/listing        { "deprecationMessage": "Replaced by weather-pro." }

Both return 204. Listing requires a published version (409 otherwise). Removing keeps existing installs working and shows them the message.

Yank a version / delete a plugin

POST   /plugins/{id}/versions/{versionId}/yank
DELETE /plugins/{id}

Yank returns the version summary. You can't yank the last published version (409). Installs on Latest that sit on a yanked version move to the highest remaining version they have already agreed to; an install pinned to it has no version to run until its owner picks another. Delete returns 204, and 409 for any plugin that has ever had a published version, yanked or not: only a plugin that never published can be deleted.

Set a price

PUT /plugins/{id}/price
Content-Type: application/json

{ "price": { "model": "Monthly", "amountUsd": 4.99 }, "payoutCountry": "US" }

model is OneTime or Monthly. The amount is between $2.99 and $999. "price": null makes the plugin free. payoutCountry (a country code) is required the first time you price a plugin. The platform keeps 30% of each sale. An account that installs a priced plugin without a licence gets 402 with "code": "plugin_purchase_required". GET /earnings/summary, GET /earnings/sales and GET /payouts report what you have earned.

Catalog and installs

GET /marketplace                     every listed plugin, plus your own private ones
GET /marketplace/{slug}              readme, capabilities, what it can reach, installed or not

GET /marketplace takes q (search text), repeated kinds, categories and madeBy (Official, Verified or Community), isPriced, hideInstalled, sort (installs by default, newest, updated or name), offset, and limit (24 by default, 60 at most).

POST   /installations                { "slug": "weather", "versionPolicy": "Latest", "agentIds": ["…"] }
GET    /installations                optional pluginId, state (needs-you, update or pinned), q
GET    /installations/{id}
POST   /installations/{id}/configure { "settings": { "units": "metric" }, "secrets": { "apiKey": "…" } }
PATCH  /installations/{id}           { "versionPolicy": "Pinned", "pinnedVersionId": "…" }
PUT    /installations/{id}/agents    { "scope": "Chosen", "agentIds": ["…"] }
PATCH  /installations/{id}/capabilities/{activationId}  { "isEnabled": false }
POST   /installations/{id}/accept-update  { "versionId": "…" }
POST   /installations/{id}/rerun-setup
DELETE /installations/{id}
  • POST /installations returns 201 with { installationId, slug }; 409 if the account already has the plugin.
  • versionPolicy: Latest (default, also used when the value is missing or unrecognised), Pinned (needs pinnedVersionId on PATCH), or Draft (your own plugin only; 403 otherwise).
  • agentIds limits the install to those agents; leave it out for all agents. PUT …/agents changes it later: scope is All or Chosen, and agentIds is read only for Chosen.
  • PATCH …/capabilities/{activationId} turns one of the install's capabilities on or off.
  • configure takes setting values as strings and replaces the installation's non-secret settings, so send every value; secrets are write-only and never returned, and a blank secret leaves the stored one untouched.
  • accept-update accepts a version that asks for more access.
  • rerun-setup sends pluginInstalled again (after a plan upgrade, for example).
  • DELETE uninstalls: the plugin's agents (and their chats), providers, channels, live connections, sign-ins and stored secrets are removed, and pluginUninstalled is sent.

Logs

GET /plugin-logs?installationId={id}&levels=warn&levels=error&limit=100&cursor=…
GET /plugin-logs/counts?installationId={id}&since=2026-01-01T00:00:00Z

The host.log lines your account's installations produced, newest first, kept 24 hours. Filters: installationId, sessionId, repeated levels (debug, info, warn, error), since, search, repeated entrypoints. limit is 50 by default; page with cursor. counts needs installationId and since.

Inbound webhooks for plugin channels

A channel plugin that receives by webhook gets one address per connected channel:

https://webhook.agentparley.ai/webhook/plugin/{routingId}

The channel's page in the console shows it (and can replace it with a new address). Everything the vendor POSTs there reaches your channel.receive — headers, the text body, and the exact bytes for signature checks. Requests are queued on arrival, so messages sent while AgentParley deploys are delivered afterwards rather than bounced.