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

# blobhub scheduler deploy

> Make the manifest real: create schedules that don’t exist, update those that do, by alias

`deploy` is the command that writes. It validates every entry's target, then creates the schedules the revision
does not have and updates the ones that differ, matching by alias — so a second `deploy` of an unchanged manifest
issues no calls at all rather than duplicating what is already there.

It is the only command in this group that needs a writable revision, and the only one that changes anything on the
platform.

## Synopsis

```bash theme={null}
blobhub scheduler deploy -f <manifest> [--dry-run]
```

| Flag           | Default  | Description                                                                      |
| -------------- | -------- | -------------------------------------------------------------------------------- |
| `--file`, `-f` | required | Path to the scheduler manifest.                                                  |
| `--dry-run`    | off      | Write no schedule. Still resolves every target, and may create a target session. |

There is no per-schedule narrowing flag and no `--force`. A manifest describes one revision's schedule set, and
`deploy` acts on that set.

## What it does

1. Loads and validates the manifest offline. Alias grammar, duplicate aliases, `repeat` and its timing field,
   target blob and revision shapes, and the 256-character `target.description` cap are all answered before a
   single call goes out.
2. Resolves the scheduler blob and revision. This is a write: status `ready`, phase `draft` or `managed`.
3. Pages `list_schedules` to completion and keys the result by alias.
4. **Pass one** — resolves and validates every entry's target, writing no schedule.
5. **Pass two** — for each entry, creates it if absent, updates it if fields differ, and skips it entirely if not.

```text theme={null}
created session 'flash_sale_2026' for flash_sale_launch
created flash_sale_launch
updated daily_checkout_report (cron_expression)
unchanged weekly_report
```

Under `--dry-run` the schedule lines read `would create` and `would update`. The session line does not hedge,
because the session really was created.

Under `--json` the payload carries `blob`, `created`, `updated` (each with `alias` and `fields`), `unchanged`,
`sessions_created`, `dry_run` and `drift`, alongside the envelope's `schema_version`.

## Two passes, and the exact limit of that guarantee

Every target is validated before any schedule is written. A manifest whose *third* entry names a workflow that
does not exist fails with **no schedule created at all** — not the first two and then an error.

That matters more here than it would elsewhere, because a half-deployed schedule set is worse than a refused one:
the half that landed is already firing on its cron. There is no `scheduler prune` yet to take it back off, so an
accidental partial deploy leaves behind schedules you have to remove through the web UI.

[`blobhub workflow deploy`](/cli/workflow/commands/deploy) runs two passes as well, and they guarantee the
opposite thing. Its passes are upload then check, so a failure leaves a fully applied deploy and a report that
stops at the first failing definition — it cannot roll an upload back, so it makes sure nothing is half-applied
by finishing every upload first. Here the passes are validate then write, so a failure leaves nothing written at
all. Both orderings exist for the same reason: neither command can undo its own writes, so each puts the
unrecoverable step where a failure cannot strand it.

**The guarantee covers schedules. It does not cover sessions.** Pass one is what resolves each entry's
`target.session`, and resolving an alias that does not exist yet *creates* it. So a manifest that fails on a later
entry can still leave earlier entries' sessions behind:

```text theme={null}
error: SCHEDULE_TARGET_INVALID: workflow alias 'does_not_exist' is not a workflow-category definition on revision 3d1317ed-...
```

— and the first entry's session now exists on the target revision, though no schedule was written and nothing will
ever append to it. That is accepted rather than fixed: an empty session fires nothing, costs nothing, and the
corrected re-run reuses it. It is reported under `sessions_created` precisely because it is the one piece of state
a reader could not infer from the schedules that were, or were not, written. Nothing in this CLI deletes a
session.

Within pass one, each entry is validated blob → revision → workflow alias → session, in that order, and the
session is deliberately last: every check that can still refuse the target runs while refusing is free. A typo in
`workflow:` therefore leaves no session behind for its own entry.

## Create, update, unchanged

Matching is by alias, which is why `alias` is required on every manifest entry even though the platform does not
require one on a schedule. It is the manifest's identity for a schedule, and what makes a redeploy an update
rather than a duplicate.

| Verdict     | Call issued                                                                                                          |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `created`   | `create_schedule`, with the alias.                                                                                   |
| `updated`   | `update_schedule`, addressed by the schedule's id, sending every field. The named fields are the ones that differed. |
| `unchanged` | **None.** Neither call is issued — pass one still resolved the target, but nothing is written.                       |

The comparison is the same one [`diff`](/cli/scheduler/commands/diff) reports, reused rather than re-derived — the
two can never disagree about what "changed" means. Only the timing field that `repeat` gives meaning to is
compared — [`diff` explains why](/cli/scheduler/commands/diff#only-the-timing-field-that-applies-is-compared).

An update never sends an alias, so `deploy` cannot rename a schedule and cannot adopt one that has no alias.
Schedules the manifest does not cover are drift: reported, left untouched, and still firing.

## `--dry-run` still creates sessions

`--dry-run` skips the `create_schedule` and `update_schedule` calls, and nothing else. Every resolution still
happens, which means a `target.session` alias that does not exist yet **is created by the dry run**:

```text theme={null}
created session 'flash_sale_2026' for flash_sale_launch
would create flash_sale_launch
would update daily_checkout_report (cron_expression)
```

The session line is printed without the "would" hedging the schedule lines get, deliberately. Run the same dry run
twice and the second reports no session creation, because the first one already did it.

If what you want is a preview with no side effect at all, use [`diff`](/cli/scheduler/commands/diff). It is the
command built for that, and the session is the only thing the two disagree about.

## `SESSION_NEAR_SATURATION`

An advisory, on stderr, exit code unchanged:

```text theme={null}
warning: SESSION_NEAR_SATURATION: daily_checkout_report: target session already carries at least 501 execution(s) -- every fire appends one more, and workflow_executions_per_session is enforced at creation, so this schedule will eventually start failing silently with an invocation_error
```

A recurring schedule pins one session for its entire life, and every fire appends an execution to it. The
per-session execution limit is enforced when an execution is **created**, so a session that reaches it does not
break the schedule loudly — the schedule stays `active` and `enabled`, fires on time, and each fire records an
`invocation_error` instead of running anything.

Four things worth knowing about the check:

* **It counts, then stops.** Counting halts as soon as saturation is proven, so the number in the message is a
  lower bound past that point, not an exact total. A saturated session is exactly the case where a full count
  would be most expensive.
* **It runs for every entry**, whether the schedule was created, updated, or left unchanged. It is a fact about
  the session, not about what `deploy` just did to the schedule record.
* **`one_time` schedules never trigger it.** One more execution cannot saturate anything.
* **It names the configured maximum only when limits are readable**, which needs an `admin`-scoped key. With the
  `read`/`write` keys the platform recommends for automation, you get the count and no maximum.

Like every advisory it is suppressed when stderr is not a TTY, so a CI log will not carry it.

## What `deploy` cannot check for you

Two classes of failure survive a clean deploy, and both surface later.

**`cron_expression` and `timezone` are validated when the schedule is written, not before.** Neither is
constrained by any schema, and the CLI passes both through as opaque strings. A malformed expression is refused by
the platform as the write happens, surfacing as `API_COMMAND_FAILED` carrying the server's own
`invalid_cron_expression` — and because writes happen in pass two, a bad cron on a later entry *can* leave earlier
entries deployed, which is the one hole in the all-or-nothing guarantee above. Check
your syntax against [cron expressions](/blob-types/scheduler/overview#cron-expressions) first; the plausible
`0 9 * * MON-FRI *` is rejected, and `0 9 ? * MON-FRI *` is the working form.

**`target.input_data` is not validated until the schedule fires.** The scheduler's schema types it as a bare
list, so any list at all deploys successfully. The item shape is enforced by `create_execution` at fire time,
where a violation surfaces as an `invocation_error` on the schedule's execution history — a schedule that deployed
cleanly, looks healthy in [`ls`](/cli/scheduler/commands/ls), and has never once run its workflow. See
[Create Execution](/blob-types/workflow/operations/create-execution) for the shape.

## Permissions

Firing a schedule writes an execution into the target revision, so the platform requires **WRITE** on the target
blob as well as on the scheduler blob. A key with only read access to the target is refused.

The target revision itself is only read. Deploying against a target in `commit` phase is fine, and is the normal
case — what you schedule is usually a committed workflow.

## Errors

| Code                                                                                               | When                                                                                                                                                                                                                                                                                                                           |
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MANIFEST_NOT_FOUND`, `MANIFEST_INVALID`, `MANIFEST_TYPE_MISMATCH`, `MANIFEST_VERSION_UNSUPPORTED` | The manifest is absent or unusable. Answered offline, before any call.                                                                                                                                                                                                                                                         |
| `SCHEDULE_ALIAS_REQUIRED`, `SCHEDULE_ALIAS_INVALID`                                                | An entry has no `alias`, or one that fails `^[a-z0-9_-]{6,42}$`.                                                                                                                                                                                                                                                               |
| `SCHEDULE_TARGET_INVALID`                                                                          | An entry's target blob, target revision, or `workflow:` alias does not resolve. Raised in pass one, so no schedule was written.                                                                                                                                                                                                |
| `SESSION_NOT_ACCESSIBLE`, `SESSION_NOT_OPEN`                                                       | A `target.session` id does not resolve, or the session it names is closed. Also pass one.                                                                                                                                                                                                                                      |
| `BLOB_NOT_ACCESSIBLE`, `BLOB_NOT_WORKFLOW`, `REVISION_NOT_ACCESSIBLE`, `REVISION_NOT_WRITABLE`     | The scheduler blob cannot be resolved, is a blob of another domain, or its revision is not `ready` in phase `draft`/`managed`.                                                                                                                                                                                                 |
| `SCHEDULE_NOT_ACCESSIBLE`                                                                          | A schedule `list_schedules` returned moments earlier is gone by the time `update_schedule` addresses it — deleted concurrently, or no longer reachable with this key.                                                                                                                                                          |
| `API_COMMAND_FAILED`                                                                               | The platform refused a write, carrying its own `error` string: `invalid_cron_expression`, a timezone the scheduling service rejects, or `alias_in_use` where the revision gained that alias between the listing and the create. Unlike the `workflow` group, this group does not translate `alias_in_use` into `ALIAS_IN_USE`. |
| `FORMAT_UNSUPPORTED`, `FORMAT_PARSE_ERROR`                                                         | The manifest has an unsupported extension, or does not parse.                                                                                                                                                                                                                                                                  |
| `CREDENTIALS_NOT_FOUND`, `PROFILE_NOT_FOUND`, `INSECURE_CREDENTIALS_PERMISSIONS`                   | No usable credential, or a credentials file the CLI refuses to read.                                                                                                                                                                                                                                                           |
| `API_RATE_LIMITED`, `API_TRANSIENT_ERROR`, `API_NETWORK_ERROR`                                     | A call failed after its retries.                                                                                                                                                                                                                                                                                               |
| `SESSION_NEAR_SATURATION`, `DEFINITION_LIST_TRUNCATED`                                             | Advisories, on stderr. The exit code stays `0`.                                                                                                                                                                                                                                                                                |

Every code above, with its remediation, is in [Error codes](/cli/error-codes).

## See also

* [`blobhub scheduler diff`](/cli/scheduler/commands/diff) — the same comparison, reported and never performed,
  and the only preview that creates nothing.
* [`blobhub scheduler ls`](/cli/scheduler/commands/ls) — what the revision holds after a deploy, drift included.
* [`blobhub scheduler pull`](/cli/scheduler/commands/pull) — the other direction, which is not this one's inverse.
* [Scheduler manifest](/cli/scheduler/manifest) — the four constraints checked before anything is written.
* [Scheduler blob type](/blob-types/scheduler/overview) — cron syntax, timezones, date windowing and schedule
  states, as the platform defines them.
