> ## 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.

# Scheduling

> plungeai_schedule creates and manages cron jobs on the platform scheduler. A job belongs to the user behind your key, and every run of a workflow, agent, query or mission_ref job lands in your normal execution history (heartbeats are the exception, 11.3), where you read it like any other result …


Source: `orchestration/mcp-gateway/extras.ts` (`ScheduleSchema`, `plungeaiSchedule`, `ownedJob`), `orchestration/mcp-gateway/tools.ts` (scheduler ids in `getResult` / `getWorkflowStatus`), `orchestration/mcp-gateway/server.ts` (`TOOL_DEFS`), `orchestration/scheduler/scheduler.ts` (`/jobs` routes), `orchestration/scheduler/SchedulerDO.ts` (heartbeat, model pin, local bots, manual runs), `orchestration/scheduler/utils/cronParser.ts`, `orchestration/scheduler/utils/d1Manager.ts`, `orchestration/scheduler/README.md`.

`plungeai_schedule` creates and manages cron jobs on the platform scheduler. A job belongs to the user behind your key, and every run of a workflow, agent, query or `mission_ref` job lands in your normal execution history (heartbeats are the exception, [11.3](#113-create-a-job)), where you read it like any other result ([Runs, results & follow-ups](/mcp/runs-results)). The examples use `https://mcp.plungeai.com/v1`. `https://api.plungeai.com/v1/mcp` is the same server.

The tool is not marked read-only, but `stats`, `list`, `get` and `runs` only read. Full parameter table: [14.19](/mcp/tool-reference#1419-plungeai_schedule).

## 11.1 How a schedule runs

The scheduler checks for due jobs continuously; the shortest cadence is one minute. A job id looks like `job_<timestamp>_<random>`.

Everything this tool creates, except heartbeats, runs as a **workflow job**. Agent and query jobs are wrapped into a saved workflow first, so every run goes through the standard engine pipeline and shows up in your history:

| You create | What is saved | What each run does |
|---|---|---|
| `job_type: "workflow"`, `target` = one of your workflow ids | the job, linked to that workflow | runs that workflow |
| `job_type: "agent"`, `target` = an agent id, `parameters.prompt` | a new workflow **Scheduled: &lt;name&gt;** with one task (that agent, that prompt), and the job | runs the wrapper workflow |
| `job_type: "query"`, `target` = `{"agent": "<id>", "query": "<text>"}` | the same kind of wrapper workflow | runs the wrapper workflow |
| `mission_ref` = a pre-built agent card id | a wrapper workflow **Scheduled: &lt;name&gt;** with one harness task pointing at the card | runs the card, resolved live at fire time |
| `job_type: "heartbeat"` | the job | checks a condition each tick and acts only when it is met ([11.3](#113-create-a-job)) |

Wrapper workflows are ordinary saved workflows: they appear in `plungeai_list_workflows` and in Studio. Deleting a job does not delete its wrapper.

A workflow run is handed to a background run and the scheduler moves on; the run finishes on its own and the job's history is completed afterwards. Results can also be delivered to you (`parameters.deliver`, [11.3](#113-create-a-job)).

## 11.2 Actions

| `action` | Needs | Answer |
|---|---|---|
| `stats` | nothing | `# Schedule stats` with `Jobs: N (a active, p paused)` |
| `list` | optional `limit` (default 50) | `# Your scheduled jobs (N)` and a table: Name, Type, Schedule, Status, Next run (epoch milliseconds), ID. Or `No scheduled jobs. Create one with action "create" (name, job_type, target, schedule).` |
| `get` | `job_id` | `# <name>` and the whole job as JSON |
| `create` | see [11.3](#113-create-a-job) | ``Created scheduled job **<name>** (`<job_id>`) — <cron>.`` plus a note naming the wrapper workflow, if one was made |
| `update` | `job_id` and the fields to change | ``Updated job `<job_id>`.`` |
| `pause` | `job_id` | ``Job `<job_id>` is now paused.`` |
| `resume` | `job_id` | ``Job `<job_id>` is now active.`` |
| `delete` | `job_id` | ``Deleted scheduled job `<job_id>`. Its run history is preserved — action "runs" with this job id still shows past executions.`` |
| `run_now` | `job_id` | ``Triggered job `<job_id>` — execution `<id>` (fetch the output with plungeai_get_result).`` |
| `runs` | optional `job_id`, `limit` (default 20) | `# Scheduled runs (N)` and a table: Job, Status, Started, Execution ID, Error |
| `wake` | `job_id`, optional `note` | ``Woke job `<job_id>` — it runs at <time>.`` The job's saved workflow runs once through the scheduler's `runAgain`, with `note` as its input; a pending wake is re-timed, never stacked |
| `webhook_mint`, `email_mint`, `hooks_mint` | `job_id`; webhook: `prompt_template`, `scheme`, `filters`; email: `allowed_from`; hooks: `url`, `events` | the scheduler's answer as JSON (token, URL or address part, signing secret). **A secret is in this answer once** and never listed again |
| `webhook_list`, `email_list`, `hooks_list` | `job_id` | the job's triggers of that kind as JSON (never a secret) |
| `webhook_delete`, `email_delete`, `hooks_delete` | `job_id`, `trigger_id` | ``Deleted <kind> trigger `<id>` of job `<job_id>`.`` An id the job does not have is `Trigger not found.` |
| `webhook_rotate`, `email_rotate` | `job_id`, `trigger_id` | mints a successor with the old trigger's settings, then revokes the old one (the same body as One API `POST /v1/triggers/{webhook\|email}/{token}/rotate`): ``Rotated <kind> trigger `<id>` of job `<job_id>`: the old one is revoked.`` and the new trigger as JSON, with `"rotated": "<old id>"`. **The new secret is in this answer once.** Mint comes first, so a failure in between leaves two live triggers, never none. Hooks have no rotate: delete and mint again |

`stats` and `list` read up to 1,000 of your jobs and leave deleted jobs out. In `list`, agent and query jobs show the type `workflow`, because they were wrapped.

## 11.3 Create a job

Every create needs `name` and `schedule`. The rest depends on the kind of job. The tool picks the path in this order: `job_type: "heartbeat"` first, then `mission_ref` (when set, `job_type` and `target` are not needed), then the plain `job_type` + `target` path.

**`schedule`** is a five-field cron expression: `minute hour day-of-month month day-of-week`. For a one-time run use `schedule: "@once"` with `run_at`, an ISO-8601 time with a UTC offset (for example `2026-10-06T09:00:00Z`); without `run_at` the call answers `run_at required` and creates nothing. `ends_at` (ISO-8601, on `create` or `update`) stops a recurring job after that time. The webhook and email triggers belong to workflow jobs, and the hooks (outbound, signed run events) to any job you own; the same set is on the One API as `/v1/schedules` and `/v1/triggers`.

| Field | Values |
|---|---|
| minute | 0–59 |
| hour | 0–23 |
| day of month | 1–31 |
| month | 1–12 |
| day of week | 0–6, Sunday = 0 |

`*` is any value, `,` a list (`1,15`), `-` a range (`1-5`), `/` a step (`*/15`).

| Schedule | Cron |
|---|---|
| every 5 minutes | `*/5 * * * *` |
| every hour | `0 * * * *` |
| every day at 09:00 | `0 9 * * *` |
| weekdays at 09:00 | `0 9 * * 1-5` |
| every Monday at 07:00 | `0 7 * * 1` |
| the 1st of every month at midnight | `0 0 1 * *` |

`@once` fires through `run_at`: the job runs once at that time and is then done. For a run right now, use `run_now`.

**`timezone`** is an IANA zone name such as `America/Los_Angeles` or `Europe/Berlin`. The cron is read in that zone. Without it the cron is read in UTC. An invalid zone is refused, never silently treated as UTC.

**`description`** is optional free text.

**`parameters`** is an object of job extras:

| Key | Meaning |
|---|---|
| `prompt` | the per-run goal or input. Required for agent jobs. For a workflow job it is the run's input (the scheduler reads `initial_input`, then `input`, then `prompt`). For a `mission_ref` job it is the goal, default `Run your mission for today.` |
| `inputs` | named inputs for a workflow job |
| `deliver` | where results go: an array of `{channel, chat_id?, to?}` with `channel` one of `inapp`, `email`, `slack`, `telegram`, `whatsapp`, `discord`, `teams`. A bare `{channel:"slack"}` sends you a direct message; a bare `{channel:"email"}` mails your registered address; any other email address must be a confirmed recipient. A bare string such as `"slack"` is accepted and stored as `{channel:"slack"}` |

### Workflow jobs

`job_type: "workflow"` and `target` = the id of one of your saved workflows (from `plungeai_list_workflows`).

- The workflow must be in your account, or the answer is ``No workflow `<id>` found in your account — pass a workflow id from plungeai_list_workflows, or build one with plungeai_build_workflow.``
- The scheduler refuses a workflow that names an agent id that does not exist: `Scheduler rejected the job: Cannot schedule — no such agent: <ids>. …`
- **Model pin.** When you create the job, the first `model:` line in the workflow YAML is recorded, at any indentation (a task's `model:` counts). If that first `model:` value changes later, each run is skipped instead of running on the new model. The run is still recorded as `completed` in `runs`, and the notice `⚠️ Skipped this run: the model changed from <old> to <new> since you scheduled it. Re-save the schedule to accept the new model.` goes to the job's `parameters.deliver` targets. With no `deliver` the skip is silent. The `update` action does not re-record the model; create a new job to accept it. A workflow with no `model:` line anywhere is not pinned.
- **Local bots.** A workflow of kind `bot` whose YAML says `local: true` runs on your own computer. When your computer is offline at fire time, the run is skipped instead of failing, and a notice (`⏭️ Skipped "<name>" — this bot runs on your computer, which was offline …`) goes to the job's `parameters.deliver` targets, if it has any.

### Agent and query jobs

- **Agent:** `job_type: "agent"`, `target` = an active registry agent id, and `parameters.prompt` = what the agent should do each run. Without the prompt: `Agent schedules need a prompt — pass parameters: { "prompt": "what <agent> should do each run" }.`
- **Query:** `job_type: "query"`, `target` = a JSON string `{"agent": "<agent-id>", "query": "<what to run>"}`. Both keys are required.

The agent is checked when you create the job: an unknown or inactive agent is refused before anything is saved in the scheduler (`No such agent: …` / `Not permitted: … is not status:active …`). Take agent ids from `plungeai_list_agents` ([Discovery](/mcp/discovery)).

### Pre-built agent cards (`mission_ref`)

Pass `mission_ref` with the card id, plus `name` and `schedule`. Each run executes the card as a one-task harness workflow with `parameters.prompt` as its goal. The card is resolved live at fire time, so edits to the card apply to future runs automatically.

### Heartbeats

A heartbeat is a proactive check, such as "check my inbox for urgent email every 30 minutes". It needs `job_type: "heartbeat"`, `name`, `schedule`, `check_agent` (or `check_workflow`) and `condition_prompt`; `trigger_workflow`, `notify_channel` and `notify_chat_id` are optional. The tool builds the job's target from these fields.

Each tick:

1. `check_agent` is asked `condition_prompt` and told to answer only YES or NO. With `check_workflow` instead, that saved workflow runs as you with `condition_prompt` as its input and its last task must answer YES or NO.
2. On NO, nothing else happens.
3. On YES, `trigger_workflow` (a workflow id) runs with `condition_prompt` as its input, if set.
4. Then a notification goes to `notify_channel` / `notify_chat_id`, if both are set.

A heartbeat's check and its `trigger_workflow` run directly on the engine as system runs and create no execution in your history. `run_now` and `runs` show only the scheduler's own `exec_…` id, which the result tools cannot open, and the triggered workflow's output does not appear in `plungeai_executions`. Set `notify_channel` / `notify_chat_id` to see the outcome.

`check_agent` must be an active agent. `check_workflow` must be the id of a workflow you own, with no unknown agent in it. Studio's Gmail trigger is a heartbeat built this way: a Gmail search task, then a YES/NO judge. `notify_channel` is one of `telegram`, `whatsapp`, `discord`, `slack`, `teams`, `email`. WhatsApp notifications are **partial**: the WhatsApp adapter needs a phone-number id a heartbeat does not carry, so a WhatsApp heartbeat sends nothing today.

### Create errors

All `isError: true`:

| Answer | Cause |
|---|---|
| `create requires: name, job_type (agent\|query\|workflow), target, schedule (cron).` | a plain create with a field missing |
| `heartbeat create requires: name, schedule (cron), check_agent or check_workflow …, condition_prompt. …` | a heartbeat with a field missing |
| `mission_ref create requires: name, schedule (cron). …` | a `mission_ref` create with a field missing |
| `Scheduler rejected the job: Failed to parse cron expression: …` | not a valid five-field cron |
| `Scheduler rejected the job: Invalid timezone: <zone>` | not an IANA zone |
| `For job_type "query", target must be JSON: {"agent": "<agent-id>", "query": "<what to run>"}.` / `Query target must include both "agent" and "query".` | a malformed query target |
| `The scheduler hit a temporary storage error while saving the job — please retry; it usually succeeds on the second attempt.` | a transient storage error |

Heartbeat rejections read `Scheduler rejected the heartbeat: …`.

**The wrapper is saved first.** For agent, query and `mission_ref` jobs, the **Scheduled: &lt;name&gt;** workflow is saved before the scheduler validates the job. If the scheduler then rejects it (a bad cron, a bad zone), the wrapper stays in your workflows. Delete it with `plungeai_workflow {action:"delete"}` ([Workflows](/mcp/workflows)).

Try asking:

- *"Run the S&P 500 weekly performance analysis every Monday at 7am."* Your client finds the workflow, then creates a workflow job with `0 7 * * 1` (and your time zone).
- *"Every weekday at 9, have the news agent brief me on AI chip news and send it to Slack."*

Create a workflow job. This saves a job that runs, and costs credits, on every tick (not verified here):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"run the S&P 500 weekly performance analysis every Monday at 7am","action":"create","name":"S&P 500 weekly","job_type":"workflow","target":"<workflow_id>","schedule":"0 7 * * 1","timezone":"America/New_York","parameters":{"deliver":[{"channel":"email"}]}}}}'
```

Schedule one agent directly (the tool saves the wrapper workflow for you):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"every weekday at 9 brief me on AI chip news","action":"create","name":"AI chip news","job_type":"agent","target":"newsapi-get-articles","schedule":"0 9 * * 1-5","parameters":{"prompt":"Top AI chip news of the last 24 hours, five bullets with links.","deliver":["slack"]}}}}'
```

## 11.4 Update, pause, resume, delete

`job_id` must be one of your jobs; anything else answers `Job not found.` Passing an execution id by mistake (`exec_…`) answers `That looks like an execution id (exec_…), not a job id (job_…). …`.

**`update`** changes only what you pass: `name`, `description`, `schedule`, `timezone`, `target`, `parameters`. `parameters` replaces the stored object, so send the whole object. Other keys (`job_type`, `mission_ref`, the heartbeat fields) are ignored on update.

- A workflow job runs the workflow it was linked to at create. A new `target` does not change that link. To schedule a different workflow, create a new job and delete the old one.
- A rejected update answers `Scheduler update failed (HTTP <status>).` without the reason. Check the cron and the zone: an invalid cron comes back as HTTP 500, an invalid zone as HTTP 400.

**`pause`** and **`resume`** set the job to `paused` or `active`.

**`delete` is soft.** The job stops and is marked deleted; its run history stays readable with `runs` and the job id. A deleted job refuses `update`, `pause` and `resume` (`This job is deleted — it cannot be modified. …`) and `run_now` (`This job is deleted — it cannot be run. …`). Deleting it again answers ``Job `<job_id>` is already deleted.`` Deleted jobs drop out of `list` and `stats`. The wrapper workflow of an agent, query or `mission_ref` job is not deleted.

**Busy storage.** When the scheduler's storage is briefly throttled, the job lookup is retried once. If it still fails, the answer is `The scheduler’s storage is briefly busy (D1 throttling) — the job most likely still exists. Retry in a few seconds.`, never "Job not found".

Try asking: *"What do I have scheduled?"*, *"Pause the S&P 500 job."*, *"Move it to 8am."*, *"Delete that schedule."*

Pause a job. This changes its state (not verified here):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"pause the S&P 500 job","action":"pause","job_id":"<job_id>"}}}'
```

## 11.5 Run now and run history

**`run_now`** runs a job immediately, outside its cadence, and reports the scheduler's real outcome, not just "triggered".

- Success: ``Triggered job `<job_id>` — execution `<id>` (fetch the output with plungeai_get_result).`` A workflow run is handed to a background run, so this answer comes back once the run has started, not when it has finished. Poll `plungeai_get_workflow_status` with that id, then read it with `plungeai_get_result`.
- When the scheduler has only its own run id: ``Triggered job `<job_id>` — scheduler run `exec_…` (see action "runs").``
- Failure: ``Run failed (scheduler run `exec_…`): <reason>`` (`isError: true`).

**`runs`** lists run history:

- with `job_id`: that job's runs, including the runs of a deleted job;
- without `job_id`: the runs of your current jobs. Runs of deleted jobs are left out; ask for them by job id.

`limit` defaults to 20 (1–100). The columns are Job, Status, Started, Execution ID and Error (first 120 characters). The **Execution ID** is the id `plungeai_get_result` and `plungeai_get_workflow_status` accept: the platform execution id, or the engine's `exec-…` id. Heartbeat runs, and only very old runs of other jobs, fall back to the scheduler's own `exec_…` id, which those tools redirect back here. `No executions for your jobs yet.` means there are none.

Try asking: *"Run it now."*, *"Show me the run history of that job."*

Run history (read-only):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"show me the run history of that job","action":"runs","job_id":"<job_id>","limit":10}}}'
```

Your schedule at a glance (read-only):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"what do I have scheduled?","action":"stats"}}}'
```

Run a job now. This runs the job and costs credits (not verified here):

```bash
curl -s https://mcp.plungeai.com/v1 \
  -H "Authorization: Bearer $PLUNGE_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"plungeai_schedule","arguments":{"user_request":"run it now","action":"run_now","job_id":"<job_id>"}}}'
```
