> ## Documentation Index
> Fetch the complete documentation index at: https://www.plungeai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Bots

> A bot is a saved workflow (kind: bot) that runs on a clock, answers messages, keeps its own memory and files, and stays inside a policy you set. This chapter is the REST surface for all of it: save a bot, set what it may do and spend, watch and answer its runs, schedule it, start it from …


A bot is a saved workflow (`kind: bot`) that runs on a clock, answers messages, keeps its own memory and
files, and stays inside a policy you set. This chapter is the REST surface for all of it: save a bot, set
what it may do and spend, watch and answer its runs, schedule it, start it from outside, read what it
keeps, bind it to a chat, and call it by id. These routes read and write the same rows that Ocean Studio and
the MCP server show. Base URL is `https://api.plungeai.com`; every route takes an `ozk_` key
([Authentication](/one-api/authentication)).

Four rules hold on every route in this chapter:

- **Your rows only.** The key's account is the tenant. Another account's id answers the same
  `404 <resource>_not_found` as an id that does not exist, with no hint (`workflow_not_found`,
  `execution_not_found`, `schedule_not_found`, `trigger_not_found`, `group_not_found`,
  `template_not_found`, `skill_not_found`, `memory_not_found`).
- **Four wires.** JSON by default; YAML in (`Content-Type: text/yaml`) and out on the same routes;
  `format=` or `Accept` picks the response format (`json`, `yaml`, `markdown`, `text`); an unknown format
  is `400 invalid_format` ([Workflows](/one-api/workflows)). Two routes answer in their own type: the
  workspace file download (raw bytes) and the execution stream (server-sent events).
- **No storage text.** A storage or upstream failure is a generic `502` (`storage_error`,
  `upstream_error`, `scheduler_error`); the message never carries database or engine text.
- **One error shape.** `{"error": {"code", "message"}}` plus the `X-Error-Code` header
  ([Errors, limits & headers](/one-api/errors-limits)).

| Section | Routes |
|---|---|
| [16.1 Lifecycle](#161-lifecycle) | `/v1/workflows`, `/v1/workflows/{id}/versions` |
| [16.2 Policy and budget](#162-policy-and-budget) | `/v1/workflows/{id}/permissions` |
| [16.3 Executions and approvals](#163-executions-and-approvals) | `/v1/executions` |
| [16.4 Schedules and wake](#164-schedules-and-wake) | `/v1/schedules` |
| [16.5 Triggers and hooks](#165-triggers-and-hooks) | `/v1/triggers` |
| [16.6 Workspace, inbox, bindings, groups](#166-workspace-inbox-bindings-groups) | `/v1/workflows/{id}/workspace-files`, `/inbox`, `/bindings`, `/v1/bot-groups`, `/v1/channels` |
| [16.7 Memory and skills](#167-memory-and-skills) | `/v1/memory`, `/v1/skills` |
| [16.8 Templates](#168-templates) | `/v1/templates` |
| [16.9 The one door](#169-the-one-door) | `POST /v1/execute` |

Several workflows that work as one company are a campus: buildings of offices that items walk through, with
gates where a person decides. They have their own surface, `/v1/companies`, in [Companies](/one-api/companies). Their
documents (`kind: campus`, `kind: building`) are saved with the lifecycle routes of 16.1.

Source: `orchestration/api-gateway/routes/` (`saved.ts`, `bots.ts`, `executions.ts`, `schedules.ts`,
`triggers.ts`, `memory.ts`, `skills.ts`, `templates.ts`, `execute.ts`); the scheduler behind schedules
and triggers is `orchestration/scheduler/`.

## 16.1 Lifecycle

`kind` is `workflow`, `agent`, `bot` or `campaign`. These routes read and write the rows Studio's sidebar
and the MCP `plungeai_workflow` tool show.

| Method and path | What it does |
|---|---|
| `POST /v1/workflows` | save one: `{name, kind, yaml, description?, folder?}` → `201 {"workflow": {…}}`, `Location` names it |
| `GET /v1/workflows` | list yours: `kind`, `search`, `folder`, `limit` 1 to 200 (default 50), `offset` → `{workflows, total, limit, offset, has_more}` |
| `GET /v1/workflows/{id}` | the row and its live YAML: `{workflow, yaml}` |
| `PATCH /v1/workflows/{id}` | change any of `name`, `description`, `yaml`, `kind`, `folder` (`"none"` unfiles) |
| `DELETE /v1/workflows/{id}` | `204`. A bot also loses its memory and its scheduled jobs; a campaign its jobs, and its ledger is tombstoned |
| `GET /v1/workflows/{id}/versions` | saved versions, newest first |
| `POST /v1/workflows/{id}/versions` | save the current YAML as a version: `{label?}` → `201` |
| `POST /v1/workflows/{id}/versions/{versionId}/restore` | make a version the live YAML again; the restore is recorded as a new version |

The YAML is validated against the CNL schema and the kind's lint **before anything is written**: a bot is
exactly one task on the `one-agent` runtime, and a bot naming a frozen runtime such as `harness-agent`
with `posture` is refused. A refusal is `422 invalid_workflow` with `errors` and `warnings`, and the
stored row is unchanged. That holds for `PATCH` and `restore` as well, so no route writes a YAML the
lint would refuse. A `kind: campaign` create also takes `campaign_config` (the Studio campaign shape) and
sets up the ledger and its schedule.

**Save as YAML.** `POST /v1/workflows` and `PATCH /v1/workflows/{id}` also take the workflow itself as a
`Content-Type: text/yaml` body (`application/yaml` and `application/x-yaml` work too): the pure workflow YAML,
`workflow:` at the top, with no JSON string around it, so a bot's YAML is stored exactly as you wrote it and
reads back byte for byte from `GET /v1/workflows/{id}`. The metadata moves to query parameters: `kind`
(required on create; on `PATCH` it must equal the stored kind or be left out, otherwise `400 invalid_request`),
`name` (required on create unless the YAML has `workflow.name`, which is used when `?name=` is absent),
`description` and `folder`. Lint, versions and name checks are the JSON path's, so broken YAML is the same
`422 invalid_workflow`; `campaign_config` stays a JSON-only field, and the reply is the usual JSON envelope.
For example `curl -X POST -H 'Content-Type: text/yaml' --data-binary @bot.yaml "https://api.plungeai.com/v1/workflows?kind=bot&name=Competitor%20Watch"`
with your `Authorization: Bearer` header.

```bash
curl -s -X POST https://api.plungeai.com/v1/workflows \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"Competitor Watch","kind":"bot","yaml":"workflow:\n  name: Competitor Watch\n  tasks:\n    - type: harness\n      id: watch\n      agent: one-agent\n      goal: \"Check {input} for pricing changes\"\n      mission: |\n        You are Competitor Watch. Reply NO-CHANGE when nothing changed.\n        When your task is finished, call the task_complete tool with your final result.\n"}'
# → 201 {"workflow":{"id":"…","name":"Competitor Watch","kind":"bot","version":null,…}}
```

Listing is read-only and live-verified:

```bash
curl -s "https://api.plungeai.com/v1/workflows?kind=bot&limit=3" \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

```bash
curl -s https://api.plungeai.com/v1/workflows/00000000-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

## 16.2 Policy and budget

A bot's policy is stored on its row, and the engine loads it **once per run, on every door**: a run by
id (`POST /v1/workflows/{id}/execute`), the Agent Call door ([16.9](#169-the-one-door)), a schedule, a
trigger, a chat message, MCP and Studio all stop at the same caps and holds. Both routes are owner-only.

| Method and path | What it does |
|---|---|
| `GET /v1/workflows/{id}/permissions` | the stored policy: `{classes, locks, budget_usd_run, budget_usd_month}` plus `effective` (every class: the stored preset, else `allow`) |
| `PUT /v1/workflows/{id}/permissions` | replace the fields you send; fields you leave out stay |

| Field | |
|---|---|
| `classes` | a preset per permission class: `send`, `write`, `pay`, `delete`, each `allow`, `ask`, `deny` or `hand_off`. A class you leave out falls back to the bot's YAML, then to the default |
| `locks` | classes an admin locked: a locked class lifts the bot's own `allow` to `ask`. Only a team admin over the workflow (project owner or admin, company owner or admin) can set it |
| `budget_usd_run` | the cap on one run, in USD; `null` removes it |
| `budget_usd_month` | the cap on a month, in USD; `null` removes it |

`locks` from anyone who is not an admin is `403 lock_admin_required` and **nothing in that body is
written**, `classes` and the caps included. An invalid field is `422 invalid_params` and nothing is
written. A run that reaches its cap stops at the next turn and its result says so
(`[run stopped at $… per-run budget cap after N turns …]`).

```bash
curl -s -X PUT https://api.plungeai.com/v1/workflows/<bot id>/permissions \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"classes":{"send":"ask","pay":"deny"},"budget_usd_run":0.25,"budget_usd_month":10}'
# → {"classes":{"send":"ask","pay":"deny"},"locks":[],"budget_usd_run":0.25,"budget_usd_month":10}
```

## 16.3 Executions and approvals

Every run is an execution, whichever door started it: the API, the CLI, MCP, Studio, a schedule. Its id is
the `x-execution-id` header of the call that started it (the `workflow_id` in the JSON answer; it is your
`x-trace-id` when you sent one), or the `execution_id` a schedule's run returns. A run can
stop to ask you something or to hold an action for approval; answering it is the human-in-the-loop (HITL)
step, and these routes are how an API client takes it.

| Method and path | What it does |
|---|---|
| `GET /v1/executions` | yours, newest first: `source`, `status` (`running`, `completed`, `failed`, `cancelled`), `workflow_id`, `limit`, `offset` → `{executions, count, limit, offset, has_more, next_offset}` |
| `GET /v1/executions/{id}` | the status: poll this |
| `GET /v1/executions/{id}/output` | the final result; `format=text` is the bare content, `markdown` what the MCP plane renders |
| `GET /v1/executions/{id}/conversation` | the turns added after the run by `continue` and `followup` |
| `GET /v1/executions/{id}/files` | the files the run produced (spreadsheets, documents, decks) |
| `POST /v1/executions/{id}/continue` | answer a paused run, or approve its held action |
| `POST /v1/executions/{id}/followup` | message a run: queued while it runs, a new turn once it finished |
| `POST /v1/executions/{id}/steer` | queue a message for a running execution |
| `POST /v1/executions/{id}/cancel` | stop a running execution (cooperative: at its next turn or tool boundary) |
| `GET /v1/executions/{id}/stream` | re-attach to a running execution as server-sent events |
| `DELETE /v1/executions/{id}` | remove the history row and the stored result |

`status` is `running`, `completed`, `incomplete`, `failed` or `cancelled`. A run that finished its turn and
waits on you reads `needs_input` (a question) or `needs_approval` (a held action), with a `continuation`
that carries the question or the pending action (its summary, price and permission class).

**Answering a paused run.** `POST …/continue` takes `{"answer": "…"}` to reply to a question (or to deny
or redirect an approval), or `{"approve": true}` to approve the held action with its stored token.
`allow_scope` (with `approve`) says how far the approval reaches: `once` (this call), `run` (the same tool
for the rest of the run) or `always` (the bot's stored rule: set it with `PUT …/permissions`, [16.2](#162-policy-and-budget));
any other value is `once`. The call answers when the agent has replied, up to about 30 seconds while an
approved action runs.

| Status and code | When |
|---|---|
| `400 missing_answer` | neither `answer` nor `approve: true` |
| `409 not_paused` | the run already completed: use `followup` |
| `410 session_expired` | no conversation session is left (it expired, or the agent is not conversational) |
| `409 execution_finished` | `steer` or `stream` on a run that is no longer running |
| `410 result_expired` | `output` of a run whose stored result is gone |

`steer` and `followup` on a run in flight answer `202 {"execution_id": "…", "queued": true}`: the message reaches the live loop
at its next turn. `followup` on a finished run starts a new turn in its conversation and answers `200`
with the reply. `stream` sends `status` (first, then on change), `progress` (one event per interior step of
the tasks you name with `?task=<id>`, up to four), `done` (the run ended, paused for you, or after five minutes with
`reattach: true`) and a `: ping` comment every 15 seconds. A synchronous run writes no interior steps, so
its stream carries status only; runs started with progress watching (async, MCP, Studio, schedules) write them.
`POST …/cancel` is the same operation as `POST /v1/workflows/executions/{id}/cancel`.

```bash
curl -s "https://api.plungeai.com/v1/executions?limit=3" \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

```bash
curl -s https://api.plungeai.com/v1/executions/00000000-0000-4000-8000-000000000003 \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

An approval round trip (a run held for approval, then approved for the rest of the run) changes state and
spends, so it is not auto-verified:

```bash
curl -s https://api.plungeai.com/v1/executions/<id> -H "Authorization: Bearer $PLUNGE_API_KEY"
# → {"execution_id":"…","status":"needs_approval","continuation":{"status":"needs_approval","pending_action":{…}}}
curl -s -X POST https://api.plungeai.com/v1/executions/<id>/continue \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"approve":true,"allow_scope":"run"}'
# → {"execution_id":"…","status":"completed","content":"…"}
```

## 16.4 Schedules and wake

| Method and path | What it does |
|---|---|
| `POST /v1/schedules` | create a schedule of a saved workflow: cron, or one time with `run_at` |
| `GET /v1/schedules` | list yours (`status`, `job_type`, `workflow_id`, `limit` ≤ 100, `offset`) |
| `GET /v1/schedules/{id}` | one schedule and its ten latest runs |
| `PATCH /v1/schedules/{id}` | change name, description, cron, timezone, status, parameters, `ends_at` |
| `POST /v1/schedules/{id}/pause` · `/resume` | set `status` to `paused` / `active` |
| `POST /v1/schedules/{id}/run` | run it now (also served at `/execute`) |
| `POST /v1/schedules/{id}/wake` | fire its workflow once, optionally later |
| `GET /v1/schedules/{id}/runs` | run history (`status`, `limit`, `offset`) |
| `DELETE /v1/schedules/{id}` | stop and remove it; the run history stays readable |

### Create

| Field | |
|---|---|
| `name`, `schedule` | required. `schedule` is a cron expression, or `@once` with `run_at` |
| `workflow_id` | the saved workflow or bot to run (required for the default `job_type: "workflow"`) |
| `job_type` | `workflow` (default) or `heartbeat` (then `target` is its spec, an object). Agent and query schedules are not created here: wrap the agent in a saved workflow first |
| `timezone` | IANA zone the cron is read in (default UTC) |
| `run_at` | ISO-8601 time **with a UTC offset**, in the future. Required when `schedule` is `@once` |
| `ends_at` | ISO-8601 time or ms epoch after which the schedule stops running |
| `parameters` | `deliver` targets, a per-run `input`, … |
| `description` | free text |

`parameters.deliver` is a list of `{channel, chat_id?, to?}`; a bare channel string such as `"slack"`
is widened. `channel` is one of `inapp`, `email`, `slack`, `telegram`, `whatsapp`, `discord`, `teams`;
anything else is `422 invalid_params` and nothing is created.

A cron schedule, every weekday at 09:00 Pacific, with a Slack delivery:

```bash
curl -s -X POST https://api.plungeai.com/v1/schedules \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"Morning digest","workflow_id":"<workflow id>","schedule":"0 9 * * 1-5",
       "timezone":"America/Los_Angeles","parameters":{"deliver":["slack"]}}'
```

A one-time run, which removes itself after it fires:

```bash
curl -s -X POST https://api.plungeai.com/v1/schedules \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"name":"One-off report","workflow_id":"<workflow id>","schedule":"@once",
       "run_at":"2026-10-06T09:00:00Z"}'
```

Both answer `201 {"schedule": {…}}`. `next_run_at`, `last_run_at` and `ends_at` are ms epoch.
The refusals:

| Status and code | When |
|---|---|
| `400 run_at_required` | `schedule` is `@once` and there is no `run_at` |
| `400 run_at_past` | `run_at` is not in the future |
| `400 invalid_request` | the scheduler refused the rest (a bad cron, a `run_at` without an offset, …): the message says which |
| `404 workflow_not_found` | no saved workflow with that id for this key |
| `422 invalid_params` | a missing or malformed field; `error.missing` names the missing ones |

### List and read

```bash
curl -s "https://api.plungeai.com/v1/schedules?limit=5" \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

`GET /v1/schedules/{id}` answers `{"schedule": {…}, "recent_runs": […]}`. A run has `id` (the
scheduler's run id), `status`, `started_at`, `ended_at`, `duration_ms`, `execution_id` and `error`.

```bash
curl -s https://api.plungeai.com/v1/schedules/job_does_not_exist \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

### Change, pause, delete

`PATCH` takes only the fields it names; `ends_at: null` clears the end date. A schedule cannot become
`@once` (create a one-time schedule instead), and `status` is `active` or `paused` only: `DELETE`
removes it. `DELETE` answers `200 {"id": "…", "deleted": true}`; after it `GET` is `404`, while
`GET /v1/schedules/{id}/runs` still lists the history.

### Run now

`POST /v1/schedules/{id}/run` runs the schedule's workflow once, immediately, and answers when the run
is accepted: `{"run_id", "execution_id", "cln_workflow_id"}`. An optional body `{"input": "…"}` replaces
the schedule's own input for this run. `run_id` is the scheduler's record (the `id` in `/runs`);
`execution_id` (`cln_workflow_id` is the same id) is the one the execution routes take, so a running
run can be stopped with `POST /v1/executions/{execution_id}/cancel` ([16.3](#163-executions-and-approvals)). A run that started and
failed is `502 execution_failed` with the `run_id` in the envelope.

### Wake

`POST /v1/schedules/{id}/wake` fires the schedule's saved workflow **once** through a separate one-time
job, without touching the schedule. A pending wake is re-timed, never stacked, so repeated wakes leave
one job.

```bash
curl -s -X POST https://api.plungeai.com/v1/schedules/<id>/wake \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"note":"the inbox has new mail","in":"10m"}'
# → {"job_id":"job_…","next_run_at":1790000000000}
```

`note` becomes the run's input. `in` delays the wake (seconds, or `"90s"`, `"10m"`, `"2h"`); left out,
it fires on the scheduler's next tick. `job_id` is the one-time job and `next_run_at` its fire time.
A schedule with no saved workflow (a heartbeat) is `422`.

## 16.5 Triggers and hooks

A schedule can also be started, or watched, from outside. Three kinds, one shape each under
`/v1/triggers/{kind}`; every call names the schedule with `schedule_id` (in the body for `POST`, in
the query for `GET` and `DELETE`).

| Kind | Mint | List | Revoke |
|---|---|---|---|
| `webhook` — an HTTPS URL that runs the schedule | `POST /v1/triggers/webhook` | `GET /v1/triggers/webhook?schedule_id=` | `DELETE /v1/triggers/webhook/{token}?schedule_id=` |
| `email` — an address that runs it | `POST /v1/triggers/email` | `GET /v1/triggers/email?schedule_id=` | `DELETE /v1/triggers/email/{token}?schedule_id=` |
| `hooks` — signed run events sent to your URL | `POST /v1/triggers/hooks` | `GET /v1/triggers/hooks?schedule_id=` | `DELETE /v1/triggers/hooks/{id}?schedule_id=` |

Mint answers `201` with `Cache-Control: no-store`. **A secret is in the mint response once and is
never listed again**; keep it. Revoking an id that is not on the schedule is `404 trigger_not_found`.
Webhook and email triggers also rotate: `POST /v1/triggers/{webhook|email}/{token}/rotate`
`{"schedule_id": "…"}` mints a successor with the same settings (new secret) and then revokes the old
token; the response carries `rotated: "<old token>"`.

### Webhook

```bash
curl -s -X POST https://api.plungeai.com/v1/triggers/webhook \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"schedule_id":"<id>","scheme":"github","prompt_template":"New PR: {pull_request.title}",
       "filters":{"all":[{"path":"action","op":"equals","value":"opened"}]}}'
# → {"token":"…","secret":"…","scheme":"github","url":"https://…/trigger/<token>"}
```

| Field | |
|---|---|
| `scheme` | how the sender signs: `generic` (default, `X-Webhook-Signature` = hex HMAC-SHA256 of the raw body), `github`, `gitlab`, `standard-webhooks` |
| `prompt_template` | the run's input; `{dotted.path}` fills in a field of the JSON payload |
| `filters` | `{all?: Rule[], any?: Rule[]}`, `Rule = {path, op: equals\|contains\|exists, value?}`, at most 20 rules; a `path` starting `headers.` reads a request header. A payload that does not match is acknowledged (204) and does not run |

The sender POSTs to `url` with the signature header its scheme names. A bad signature or a timestamp
more than five minutes off is `401`; a repeated delivery id within an hour is `409`.

### Email

`POST /v1/triggers/email` takes an optional `allowed_from` (comma-separated addresses or domains) and
answers `{"token", "address_local_part": "bot+<token>"}`: the address is that local part at the Email
Routing domain your operator set up. `allowed_from` is a sender filter, weaker than a webhook signature.
Only schedules of a saved workflow take a webhook or email trigger.

### Outbound hooks

`POST /v1/triggers/hooks` `{"schedule_id", "url", "events": ["run.finished", "run.failed"]}` registers
an HTTPS URL (public, no credentials in it; at most 10 per schedule) and answers `{"id", "secret"}`.
`events` is any of `run.started`, `run.finished`, `run.failed`, `run.needs_approval`. Each delivery
carries `X-Ocean-Delivery-Id`, `X-Ocean-Timestamp` and `X-Ocean-Signature: sha256=<hex>`, the HMAC-SHA256
of `"<delivery id>.<timestamp>.<raw body>"` under the secret: recompute it over the raw body, compare in
constant time and reject an old timestamp. Delivery is best effort (one retry on a network error, a
timeout, 408, 429 or 5xx) and covers scheduler-path runs: cron, triggers, `/run`. A one-time schedule
deletes itself when its run ends, and its hooks with it.

The receiver contract in full is `orchestration/BOT-README.md`, "Outbound lifecycle webhooks".

## 16.6 Workspace, inbox, bindings, groups

What a bot keeps and who it talks to, besides its memory. Every `/v1/workflows/{id}/…` route here is
owner-only.

| Method and path | What it does |
|---|---|
| `GET /v1/workflows/{id}/workspace-files` | the files the bot keeps between runs (`workspace: bot`), newest first: `{files, truncated, workspace_id}` |
| `GET /v1/workflows/{id}/workspace-files/download?path=` | one file as raw bytes, with its stored content type and `Content-Disposition: attachment` |
| `GET /v1/workflows/{id}/inbox` | bot-to-bot messages queued for it: `{messages}`, read-only; the bot drains them at the start of its next run |
| `GET /v1/workflows/{id}/bindings` | the paired chats bound to this bot |
| `POST /v1/workflows/{id}/bindings` | bind a paired chat: `{channel, sender_id}` → `201` |
| `DELETE /v1/workflows/{id}/bindings/{channel}/{sender_id}` | unbind it: `200 {"deleted": true}` |
| `GET /v1/channels` | your paired chats per channel |
| `POST /v1/channels/pair` | `{channel}` → `{code, expires_at}` |
| `GET /v1/bot-groups` · `POST /v1/bot-groups` | list; create `{name, members}` → `201` |
| `PUT /v1/bot-groups/{id}` · `DELETE /v1/bot-groups/{id}` | replace (same body and checks as create); delete (`200 {"deleted": true}`, the member bots are untouched) |
| `GET /v1/bot-groups/{id}/thread` | the messages sent to the group, oldest first, read-only |

**Workspace files.** `path` must name a file the listing shows, inside the workspace. `..`, an absolute
path outside it and the encoded forms of either are `400 invalid_path`; a path that is not listed is
`404 file_not_found`; a file over 20 MB is `413 file_too_large`.

**Channels and bindings.** The channels are `telegram`, `whatsapp`, `discord` and `teams`. Pair a chat
first: `POST /v1/channels/pair {"channel": "telegram"}` returns a `code` that is good for 10 minutes; send
it to the channel's bot from the chat, and the chat appears in `GET /v1/channels` under
`channels.telegram.targets` with its `sender_id`. Then bind it: `POST /v1/workflows/{id}/bindings`
`{"channel": "telegram", "sender_id": "…"}`. A chat talks to **one** bot, so binding it again moves it. A
chat you did not pair is `422 not_paired`; an account at its binding limit is `409 binding_limit`; unbinding
a chat that is bound to another bot is `404 binding_not_found`. Where channel bindings are not switched on
yet, `GET` answers `{"bindings": [], "pending": true}` and the writes answer `503 not_enabled`.

**Groups.** A group is a named set of 2 to 6 of your own saved bots. A bot that calls `message_bot` with
`to=group:<name>` reaches every member. `name` is 1 to 40 letters, digits, `_`, `.` or `-` and is unique
per account (`409 name_taken`; a bad name is `422 invalid_name`). `members` must all be your bots, with no
repeats (`422 invalid_members`). Another account's group id is `404 group_not_found`.

```bash
curl -s https://api.plungeai.com/v1/bot-groups \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

```bash
curl -s https://api.plungeai.com/v1/channels \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

## 16.7 Memory and skills

**Memory** is the account's long-term memory: facts that every bot and agent you run can recall. (A
bot's own scratch memory is separate; it is wiped when the bot is deleted.) These routes read and write the
same stores as the MCP `plungeai_memory` tool.

| Method and path | What it does |
|---|---|
| `GET /v1/memory/recall?q=&limit=` | your entries, optionally only those containing `q`: `{entries: [{id, scope, text}], count}` |
| `POST /v1/memory/remember` | `{text, scope?}` with `scope` `memory` (default) or `user` (your profile) → `201`; text already stored is a `200` no-op |
| `POST /v1/memory/forget` | send exactly one of `{id}` or `{text}`: `{removed: n}` |
| `GET /v1/memory/runs?q=&limit=` | full-text search of your past runs: `{run_id, ts, goal, snippet}` |
| `GET /v1/memory/runs/{id}` | one past run's stored result |

An entry's `id` is `mem_` plus twelve hex characters, derived from its scope and text. `forget {id}` removes
that one entry (`404 memory_not_found` if your stores do not hold it); when the entry's text is also part of
another entry the id is ambiguous and the answer is `409 memory_conflict`, so use `forget {text}`, which
purges every entry containing the text from both stores. A write the store refuses (it is full, or the
text matches a blocked pattern) is `422 memory_rejected`, with `current_entries` so you can consolidate.

**Skills** are private markdown skills you saved. They are injected into a mission with
`skills: [<name>]` ([Skills, plugins & capability injection](/one-api/skills-plugins-capabilities)).

| Method and path | What it does |
|---|---|
| `GET /v1/skills` | yours: `{skills: [{id, description}], count}`; platform skills are not listed |
| `POST /v1/skills/learn` | save one: `{name, body, description?}` → `201`. `name` is kebab-case, 2 to 64 characters; `body` is markdown, up to 65,536 characters |
| `DELETE /v1/skills/{id}` | `204` |

Saving a name you already own replaces the skill; a name held by a platform skill or by another account is
`409 skill_name_taken`. A skill that is not yours (or does not exist) is `404 skill_not_found`. If the
skill registry cannot answer, `GET` and `DELETE` are `502 registry_unavailable`: that is not an empty list
and nothing was deleted. Having an agent distil a skill from a URL or a text is the MCP `plungeai_learn`
tool; this route saves a skill you wrote.

```bash
curl -s "https://api.plungeai.com/v1/memory/recall?limit=1" \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

```bash
curl -s https://api.plungeai.com/v1/skills \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

## 16.8 Templates

Starter workflows, agents and bots. A template is a public card; using one copies its YAML into your
account as a saved workflow of your own.

| Method and path | What it does |
|---|---|
| `GET /v1/templates?kind=&category=&limit=` | active templates, featured first: `{templates: [{id, name, kind, category, difficulty, usage_count, description}], count}`; `kind` is `workflow`, `agent` or `bot` (else `422 invalid_params`) |
| `GET /v1/templates/{id}` | `{template, yaml}` |
| `POST /v1/templates/{id}/use` | `{name?}` → `201 {"workflow": {…}}`: a new saved workflow of the template's `kind` |

A bot template keeps its own name; the others are named `<template name> (copy)`. Pass `name` to choose. The
copy goes through the same lint as [16.1](#161-lifecycle): a template whose YAML fails it is
`422 invalid_workflow` and nothing is created. An id that is not an active template is
`404 template_not_found`.

```bash
curl -s "https://api.plungeai.com/v1/templates?kind=bot&limit=3" \
  -H "Authorization: Bearer $PLUNGE_API_KEY"
```

## 16.9 The one door

`POST /v1/execute` is level 3 of the API ([Overview](/one-api)): the body is the program, and the call
**always runs through the engine**, so a saved bot keeps its stored policy ([16.2](#162-policy-and-budget)).
Nothing above is replaced by it: `POST /v1/workflows/{id}/execute` and `POST /v1/agents/{id}/execute` answer
exactly as before. Use level 2 when you manage a resource or call it by id; use the door when the body is
the program. The body is JSON or YAML.

| Body | Runs |
|---|---|
| `agent: <id>` and `input` | your saved agent, from its live YAML (a saved agent takes only the keys below) |
| `agent: <id>@3` and `input` | snapshot 3 of your saved agent: a pinned version |
| `agent: <building block id>` and that agent's own fields | a registry agent as a one-task workflow |
| `workflow:` (an object, or a YAML string), or root `name` and `tasks:` | an inline workflow |

These root keys steer the call and never reach an agent: `format`, `input`, `inputs`, `user_context`,
`stream`, `dry_run` and `on_behalf_of`.

- **`dry_run: true`** validates and reports without running: `200 {"dry_run": true, "kind", "agent",
  "tasks": [{"id", "agent", "type"}], "warnings"}`. No run starts, no execution row is written, nothing is
  spent. A workflow that fails validation is `400 invalid_workflow`.
- **`stream: true`** answers as server-sent events, whatever `format` says.
- **`on_behalf_of: <customer id>`** runs a connection-tier agent on the account connected under that
  customer id ([7.3](/one-api/connectors#73-how-your-testers-connect-an-account)); only this door takes it, the per-id execute routes refuse it with `400 invalid_body`.

Every answer to a call the door resolved says what ran (a run, a `dry_run`, a fence refusal such as
`404 unknown_agent` or `403 agent_not_active`; `400 missing_workflow` and `404 agent_not_found` come before it and
carry none): `x-ocean-agent` (the saved agent or the building block; absent for an inline
workflow), `x-ocean-tasks` (how many top-level tasks), and `x-ocean-version` (the snapshot number for a
pinned or saved agent, `live` for a saved agent with no snapshot, `inline` for a building block or a workflow
body). The same agent id is written to the request log, so a trace shows which agent a call ran.

| Status and code | When |
|---|---|
| `404 agent_not_found` | `agent:` names no saved agent of yours (or snapshot) and no building block; another account's agent answers the same |
| `400 ambiguous_body` | `agent:` next to `workflow:` or `tasks:` |
| `400 invalid_body` | an `agent` that is not an id (letters, digits, `_ . : -`, up to 128, then optionally `@<number>`); `dry_run` or `stream` that is not a boolean; a field a saved agent does not take (`fields` names them) |
| `400 missing_workflow` | none of `agent`, `workflow` or `tasks` |
| `403 agent_not_active` | a named agent card is not `status:active` |
| `424 connection_required` / `credential_required` | a connection-tier agent and no connected account: the error carries `connect` ([7.2](/one-api/connectors#72-how-a-connected-account-flows-into-an-api-call)) |

```bash
curl -s -X POST https://api.plungeai.com/v1/execute \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
  -d '{"agent":"plan-no-such-agent@1","input":"hi"}'
```

A saved bot, pinned to a snapshot, planned but not run (`dry_run`):

```bash
curl -s -X POST https://api.plungeai.com/v1/execute \
  -H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: text/yaml' --data-binary @- <<'EOF'
agent: <bot id>@1
input: Check the pricing page
dry_run: true
EOF
# → dry_run: true
#   kind: bot
#   agent: <bot id>
#   tasks:
#     - {id: watch, agent: one-agent, type: harness}
#   warnings: []
```

A YAML body gets a YAML answer (the Content-Type mirror); send JSON, or `format: json`, for JSON. Without `dry_run` the call runs the bot. Both answers carry
`x-ocean-agent: <bot id>`, `x-ocean-tasks: 1` and `x-ocean-version: 1`.
