# API reference

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

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

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

If the email belongs to one account the response is `{ "status": "authenticated", "authenticated": { "token": "…",
"expiresAt": "…", "refreshToken": "…", "refreshExpiresAt": "…", "user": { … }, "account": { … } } }`. If it
belongs to several, it is `{ "status": "selectionRequired", "selectionRequired": { "selectionToken": "…",
"candidates": [{ "userId", "accountId", "accountName", "name" }] } }`; pick one with
`POST /auth/login/select { "selectionToken": "…", "userId": "…" }`, which returns the same `authenticated` object.

`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

```http
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

```http
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)

```http
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.

```bash
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

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

file=@weather.zip
semVer=1.0.0
```

The zip holds the [package](plugin-package-format.md) — `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).

```bash
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

```http
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

```http
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:

```http
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.

### List in Browse / remove from Browse

```http
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

```http
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

```http
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

```http
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).

```http
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

```http
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](plugin-channels.md) 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.
