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

# Bots, executions, schedules, connections and the Agent Call door — over REST

> One API 2.3.0 puts everything a saved bot needs on REST, under the same ozk key (Authorization: Bearer ozkYOURKEY).


<!-- sources-of-truth: orchestration/api-gateway/openapi-bots.ts, orchestration/api-gateway/openapi-executions.ts, orchestration/api-gateway/openapi-schedules.ts, orchestration/api-gateway/openapi-connections.ts, orchestration/api-gateway/routes/execute.ts, orchestration/api-gateway/routes/schedules.ts, orchestration/api-gateway/docs/guide-3.0/16-bots.md | last-synced: 2026-10-05 (One API 2.3.0; every field, limit and status checked against GET /v1/openapi.json) -->
One API 2.3.0 puts everything a saved bot needs on REST, under the same `ozk_` key (`Authorization:
Bearer ozk_YOUR_KEY`). The live contract is `GET https://api.plungeai.com/v1/openapi.json` (tags
`Workflows`, `Executions`, `Schedules`, `Triggers`, `Bots`, `Memory`, `Skills`, `Templates`,
`Connections`, `Agent Call`) — regenerate clients from it, never copy routes from this page. The classic planes
(discovery, tools, agents, MCP, traces, models) are in `route-map.md`.

Rules on every route here: your rows only (another account's id answers `404 <thing>_not_found`,
same as a missing one) · JSON by default, YAML in (`Content-Type: text/yaml`) and out on the same
routes; the `format` query or body field, or `Accept`, picks `json | yaml | markdown | text` (an unknown
value is `400 invalid_format`) · an error is `{"error":{"code","message"}}` rendered in that same format, and
`X-Error-Code: <code>` is on every error: branch on the header · a storage or upstream failure is a generic
`502` (`storage_error`, `upstream_error`, `scheduler_error`), never its text · a body over 1 MiB is
`413 payload_too_large` · reuse of an `x-trace-id` on an execute call is `409 duplicate_execution_id`: send a fresh UUID.

## Saved workflows and bots

| Route | Does |
|---|---|
| `POST /v1/workflows` | save `{name, kind, yaml, description?, folder?, campaign_config?}` → `201 {workflow, warnings}` and a `Location` header; `kind` is `workflow`, `agent`, `bot` or `campaign` (`campaign_config` with `kind: campaign` sets up the ledger and its schedule); `folder` names one or creates it |
| `GET /v1/workflows` | list `{workflows, total, limit, offset, has_more}`: `?kind=workflow\|agent\|bot\|campaign`, `search` (≤ 200 chars, plain text), `folder` (exact), `limit` 1–200 (default 50), `offset`; a bad `kind`, `limit` or `offset` is `422 invalid_params` |
| `GET /v1/workflows/{id}` | the row and its live YAML `{workflow, yaml}` |
| `PATCH /v1/workflows/{id}` | any of `name`, `description`, `yaml`, `kind`, `folder`; `null` clears `description` and `folder` (`folder: "none"` unfiles), `null` on anything else is `422`; moving `kind` away from `bot` or `campaign` runs that kind's teardown |
| `DELETE /v1/workflows/{id}` | `204`; a bot also loses its memory and its jobs, a campaign its jobs (its ledger is tombstoned) |
| `GET /v1/workflows/{id}/versions` | `{versions}`, newest first |
| `POST /v1/workflows/{id}/versions` | save the current YAML as a version `{label?}` (≤ 200 chars) → `201 {version}` |
| `POST /v1/workflows/{id}/versions/{versionId}/restore` | make a version live again → `200 {workflow}`; recorded as a new version; `404 version_not_found` |
| `POST /v1/workflows/{id}/execute` | run a saved workflow or bot by id (level 2) `{input?, inputs?, user_context?, format?}` → `{success, workflow_id, final_task_id, request_id, content}`; the `workflow_id` is the execution id (`plungeai-workflows` → `references/api.md`) |

The YAML is validated and linted for its kind before anything is written — on create, `PATCH` and restore: a
bot is ONE `type: harness` task on `agent: one-agent`; a refusal is `422 invalid_workflow` with `errors` and
`warnings`, and the row is unchanged. A missing or malformed field is `422 invalid_params`.

## Bot policy, workspace, inbox, chats, groups (owner only)

| Route | Does |
|---|---|
| `GET /v1/workflows/{id}/permissions` | `{classes, locks, budget_usd_run, budget_usd_month, effective}`; `effective` is the preset each class really runs under (`allow`, `ask`, `deny`, `hand_off`) |
| `PUT /v1/workflows/{id}/permissions` | body (JSON or YAML) `{classes: {send\|write\|pay\|delete: allow\|ask\|deny\|hand_off}, locks: [send\|write\|pay\|delete], budget_usd_run, budget_usd_month}` → the stored policy; fields you send replace, `null` clears a cap — never send `0` (stored, but the engine applies a cap only above 0). `locks` need a team admin (else `403 lock_admin_required`, nothing written); a bad field is `422 invalid_params` |
| `GET /v1/workflows/{id}/workspace-files` | the files the bot keeps, newest first `{files, truncated, workspace_id}` |
| `GET /v1/workflows/{id}/workspace-files/download?path=` | one listed file as raw bytes, `Content-Disposition: attachment`; `400 invalid_path`, `404 file_not_found`, `413 file_too_large` (20 MB cap) |
| `GET /v1/workflows/{id}/inbox` | queued bot-to-bot messages `{messages}` (read-only) |
| `GET /v1/workflows/{id}/bindings` | `{bindings, pending}`; `pending: true` with an empty list means channel bindings are not enabled yet |
| `POST /v1/workflows/{id}/bindings` | `{channel, sender_id}` (≤ 128 chars) → `201 {binding}`; a chat talks to ONE bot, so binding it again moves it |
| `DELETE /v1/workflows/{id}/bindings/{channel}/{sender_id}` | unbind → `200 {deleted}`; `404 binding_not_found` for a chat bound elsewhere |
| `GET /v1/channels` | your paired chats `{channels}`; `targets[].sender_id` is what a binding takes |
| `POST /v1/channels/pair` | `{channel: telegram\|whatsapp\|discord\|teams}` → `{code, expires_at}`; send the code from the chat within 10 minutes |
| `GET /v1/bot-groups`, `POST /v1/bot-groups` | `{groups}`; create `{name, members}` → `201 {group}`: 2–6 distinct bots of yours, `name` 1–40 letters, digits, `_`, `.`, `-`, for `message_bot to=group:<name>` |
| `PUT /v1/bot-groups/{id}`, `DELETE /v1/bot-groups/{id}` | replace (same body and checks) → `200 {group}`; delete → `200 {deleted}`, the member bots stay |
| `GET /v1/bot-groups/{id}/thread` | `{messages}`, oldest first, read-only |

The engine loads the stored policy once per run, so every door (REST, MCP, CLI, Studio,
schedules, triggers, chat) stops at the same caps and holds. A run that reaches its cap says so
in its result.

## Executions (every run, whichever door started it)

| Route | Does |
|---|---|
| `GET /v1/executions` | newest first `{executions, count, limit, offset, has_more, next_offset}`: `source` (api, cli, mcp, studio, …), `status` (`running`, `completed`, `failed`, `cancelled`; else `400 invalid_status`), `workflow_id`, `limit` ≤ 100 (default 20), `offset` |
| `GET /v1/executions/{id}` | poll this: `{execution_id, status, error_message, workflow_name, started_at, duration_ms, final_task_id, continuation}`; `status` is `running`, `completed`, `incomplete`, `failed`, `cancelled`, or `needs_input` / `needs_approval` when the run waits on you (`continuation` carries the question or the held action) |
| `DELETE /v1/executions/{id}` | `200 {success, deleted}` (history row, result, follow-up thread); a second call is `404` |
| `GET /v1/executions/{id}/output` | `{execution_id, status, content, final_task_id, steps, continuation}`; `404 not_ready` while running, `409 execution_failed`, `410 result_expired` |
| `GET /v1/executions/{id}/conversation` | turns added by continue/followup `{execution_id, messages}` |
| `GET /v1/executions/{id}/files` | the Office/PDF files the run produced `{execution_id, files}` |
| `POST /v1/executions/{id}/continue` | `{answer?, approve?, allow_scope?}` (`answer` ≤ 65536 chars): answer a paused run, or approve its held action; `allow_scope` `once` (default), `run`, `always`; `400 missing_answer`, `409 not_paused` (finished run: use followup), `410 session_expired`, `502 continue_failed`; runs inline, up to ~30 s |
| `POST /v1/executions/{id}/followup` | `{text}` (≤ 65536): `202 {execution_id, queued}` while running, `200` with the reply once finished; `400 missing_text` |
| `POST /v1/executions/{id}/steer` | `{text}`: queue a message for the live loop → `202 {execution_id, queued}`; `409 execution_finished` on a finished run |
| `POST /v1/executions/{id}/cancel` | cooperative stop `{success, cancelled, markers}`; `409 not_running` (same operation as `POST /v1/workflows/executions/{id}/cancel`) |
| `GET /v1/executions/{id}/stream` | SSE re-attach: events `status`, `progress` (steps of the tasks named by `?task=`, up to 4 ids), `done`, `error`, a `: ping` every 15 s; `409 execution_finished`, `429 too_many_streams` (5 per run) |

By-id reads of a run made on a customer's behalf (`POST /v1/execute` with `on_behalf_of`) take
`?on_behalf_of=<customer id>`: `GET /v1/executions/{id}`, `GET /v1/executions/{id}/output` and `GET /v1/executions/{id}/files` only (a bad id is
`400 invalid_on_behalf_of`).

## Schedules, triggers, hooks

| Route | Does |
|---|---|
| `POST /v1/schedules` | `{name, schedule, workflow_id?, job_type?, target?, description?, timezone?, parameters?, run_at?, ends_at?}` → `201 {schedule}`; `job_type` is `workflow` (default, needs `workflow_id`) or `heartbeat` (`target` is its spec object); `schedule: "@once"` needs `run_at`: ISO-8601 WITH a UTC offset, in the future (`400 run_at_required`, `400 run_at_past`; no offset is `400 invalid_request`); `parameters.deliver` is `[{channel, chat_id?, to?}]`, channel one of `inapp`, `email`, `slack`, `telegram`, `whatsapp`, `discord`, `teams`; `404 workflow_not_found` |
| `GET /v1/schedules` | `{schedules, total, limit, offset, has_more}`: `status` (`active`, `paused`), `job_type` (`workflow`, `heartbeat`, `agent`, `query`), `workflow_id`, `limit` 1–100 (default 20), `offset` |
| `GET /v1/schedules/{id}` | `{schedule, recent_runs}` (the ten latest runs) |
| `PATCH /v1/schedules/{id}` | any of `name`, `description`, `schedule` (a cron: a schedule cannot become `@once`), `timezone`, `status` (`active`, `paused`), `parameters` (replaces), `ends_at` (`null` clears) |
| `DELETE /v1/schedules/{id}` | `200 {id, deleted}`; the run history stays readable at `/runs` |
| `POST /v1/schedules/{id}/pause`, `POST /v1/schedules/{id}/resume` | `{schedule}` with `status` `paused` / `active` |
| `POST /v1/schedules/{id}/run` | run now `{input?}` → `{run_id, execution_id, cln_workflow_id}`: `run_id` is the id in `/runs`, `execution_id` is what the execution routes take; a run that started and failed is `502 execution_failed` with `run_id` |
| `POST /v1/schedules/{id}/wake` | fire the workflow once `{note?, in?}`: `note` becomes the input, `in` delays it (seconds or `"10m"`); a pending wake is re-timed, never stacked → `{job_id, next_run_at}`; `422` for a heartbeat |
| `GET /v1/schedules/{id}/runs` | `{runs, total, limit, offset, has_more}`: `status` (`running`, `completed`, `failed`, `cancelled`), `limit` 1–100 (default 20), `offset`; `execution_id` is what the execution routes take |
| `GET /v1/triggers/webhook`, `POST /v1/triggers/webhook`, `DELETE /v1/triggers/webhook/{token}`, `POST /v1/triggers/webhook/{token}/rotate` | an HTTPS URL that runs the schedule. Mint `{schedule_id, prompt_template?, scheme?, filters?}` → `201 {token, secret, scheme, url}`; `scheme` is `generic` (default), `github`, `gitlab` or `standard-webhooks`; rotate returns the successor with `rotated` |
| `GET /v1/triggers/email`, `POST /v1/triggers/email`, `DELETE /v1/triggers/email/{token}`, `POST /v1/triggers/email/{token}/rotate` | an address that runs it. Mint `{schedule_id, allowed_from?}` → `201 {token, address_local_part}` (the local part is `bot+<token>`) |
| `GET /v1/triggers/hooks`, `POST /v1/triggers/hooks`, `DELETE /v1/triggers/hooks/{id}` | signed run events to your https URL. Mint `{schedule_id, url, events?}` → `201 {id, secret}`; events `run.started`, `run.finished`, `run.failed`, `run.needs_approval`; verify `X-Ocean-Signature`; `503 unavailable` where hooks are not on yet |

Every trigger call names the schedule with `schedule_id` (body for mint and rotate, query for list and
delete); a secret is shown once, on mint, and the mint answer is never cached; `404 trigger_not_found` for a token not on that schedule,
`404 schedule_not_found` for a schedule that is not yours. A scheduler failure is `502 scheduler_error`; a refused setting is `400 invalid_request`.

## Connections — your keys, or your customer's

| Route | Does |
|---|---|
| `GET /v1/connections` | `{connections}`: status only, never a credential; `?on_behalf_of=<customer id>` lists one customer's |
| `POST /v1/connections/{provider}` | bring a key `{api_key, api_secret?, on_behalf_of?}` (≤ 4096 chars each) → `201 {provider, on_behalf_of, status: "connected"}`; checked live and stored encrypted; `400 credential_rejected` / `missing_api_key`, `404 unknown_provider` |
| `DELETE /v1/connections/{provider}` | disconnect → `204`; `?on_behalf_of=` for a customer's; `404 connection_not_found` |
| `POST /v1/connections/{provider}/link` | `{on_behalf_of?, return_url?}` (https) → `201 {url, expires_at}`: a one-use, 15-minute hosted connect URL to send a customer; `400 invalid_return_url` |

`provider` is a connector id (`google`, `azure` for Microsoft 365, `github`, `slack`, `stripe`, …). A connection-tier
agent with no connection answers `424 connection_required` (or `credential_required`) with
`error.connect = {provider, method: oauth|api_key|wallet, link}` naming where to connect it. `on_behalf_of` (1–128 chars of
`A-Za-z0-9_.-`, no `..`; `400 invalid_on_behalf_of` otherwise) is accepted on `POST /v1/execute` among the execute routes: the
run reads the credential connected under that customer id. `/v1/workflows/{id}/execute`, `/v1/agents/{id}/execute`
and `/v1/tools/{id}/execute` refuse it with `400 invalid_body`. `503 connections_unavailable` where connections are off.

## Other errors you will meet

`503 not_enabled` (bindings, bot groups, channel pairing before their migration or deploy) · `409 binding_limit`,
`409 unbind_failed`, `422 not_paired` (bind a chat you did not pair) · `409 name_taken`, `422 invalid_name` /
`invalid_members` (bot groups) · `502 upstream_error` (a store failed). The live spec lists each per operation.

## Memory, skills, templates

| Route | Does |
|---|---|
| `GET /v1/memory/recall` | `?q=&limit=` (1–100, default 50) → `{entries, count}` |
| `POST /v1/memory/remember` | `{text (1–65536 chars), scope?: memory\|user}` → `201 {id, scope, text}` (`200` when the text is already stored); `422 memory_rejected` (store full or blocked text) |
| `POST /v1/memory/forget` | exactly one of `{id}` or `{text}` → `{removed}`; `404 memory_not_found`, `409 memory_conflict` |
| `GET /v1/memory/runs`, `GET /v1/memory/runs/{id}` | `?q=` (required) `&limit=` (1–100, default 10) → `{runs, count}`; one past run `{run_id, content}`, `404 run_not_found` |
| `GET /v1/skills` | your private skills `{skills, count}`; `502 registry_unavailable` is not an empty list |
| `POST /v1/skills/learn` | `{name (^[a-z0-9][a-z0-9-]{1,63}$), body (≤ 65536), description? (≤ 60)}` → `201 {skill}`; `409 skill_name_taken` |
| `DELETE /v1/skills/{id}` | `204`; `404 skill_not_found` |
| `GET /v1/templates` | `?kind=workflow\|agent\|bot&category=&limit=` (1–100, default 50) → `{templates, count}` |
| `GET /v1/templates/{id}` | `{template, yaml}`; `404 template_not_found` |
| `POST /v1/templates/{id}/use` | `{name?}` (1–512) → `201 {workflow}`: a new saved workflow of the template's kind; a bot template keeps its name, the others are `<template> (copy)`; `422 invalid_workflow` if its YAML fails the lint |

Detail: `plungeai-memory`, `plungeai-skills-plugins`, `plungeai-discovery`.

## The Agent Call door — `POST /v1/execute` (level 3)

The body (JSON, or YAML for any other Content-Type) is the program, and the call always runs through the
engine, so a saved bot keeps its stored policy:

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

Root keys that steer the call and never reach an agent: `format`, `input`, `inputs`, `user_context`,
`stream` (`true` answers as SSE), `dry_run` (`200 {dry_run, kind, agent, tasks, warnings}`: no run, no row,
no spend), `on_behalf_of`. Answers carry `x-ocean-agent`, `x-ocean-tasks`, `x-ocean-version` (`<n>`, `live` or
`inline`). A run answers `{success, workflow_id, final_task_id, request_id, content}`.

```bash
curl -s -X POST https://api.plungeai.com/v1/execute \
  -H "Authorization: Bearer ozk_YOUR_KEY" -H "Content-Type: application/json" \
  -d '{"agent":"<saved bot id>@3","input":"Check the pricing page","dry_run":true}'
```

| Status and code | When |
|---|---|
| `404 agent_not_found` | no saved agent of yours (or snapshot) and no building block with that id |
| `404 unknown_agent` | an inline workflow names an agent the registry does not know |
| `400 ambiguous_body` / `invalid_body` / `missing_workflow` | `agent:` next to `workflow:` or `tasks:`; an unreadable body, extra fields next to a saved agent, a malformed `agent` ref, a non-boolean `dry_run`/`stream`; none of `agent`, `workflow`, `tasks` |
| `400 reserved_field` / `invalid_workflow` / `invalid_json` / `invalid_yaml` / `invalid_format` / `invalid_on_behalf_of` | a field named like an engine task key; `dry_run` found the workflow invalid; a body that does not parse; an unknown `format`; a bad customer id |
| `403 agent_not_active` | a named agent card is parked |
| `409 duplicate_execution_id` · `413 payload_too_large` · `429 rate_limited` · `502 engine_error` | a reused `x-trace-id`; a body over 1 MiB; the tier cap (`Retry-After`); the engine failed |
| `424 connection_required` / `credential_required` | a connection-tier agent and no connected account (`connect` in the error) |

The per-resource execute routes (`/v1/workflows/{id}/execute`, `/v1/agents/{id}/execute`) answer
exactly as before: the three levels all stay. Start with level 2; use the door when the body is
the program.
