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
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
- Loads and validates the manifest offline. Alias grammar, duplicate aliases,
repeatand its timing field, target blob and revision shapes, and the 256-charactertarget.descriptioncap are all answered before a single call goes out. - Resolves the scheduler blob and revision. This is a write: status
ready, phasedraftormanaged. - Pages
list_schedulesto completion and keys the result by alias. - Pass one — resolves and validates every entry’s target, writing no schedule.
- Pass two — for each entry, creates it if absent, updates it if fields differ, and skips it entirely if not.
--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 noscheduler 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 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:
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 whyalias 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.
The comparison is the same one
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.
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:
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:
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
deployjust did to the schedule record. one_timeschedules 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 theread/writekeys the platform recommends for automation, you get the count and no maximum.
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 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, and has never once run its workflow. See
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 incommit phase is fine, and is the normal
case — what you schedule is usually a committed workflow.
Errors
Every code above, with its remediation, is in Error codes.
See also
blobhub scheduler diff— the same comparison, reported and never performed, and the only preview that creates nothing.blobhub scheduler ls— what the revision holds after a deploy, drift included.blobhub scheduler pull— the other direction, which is not this one’s inverse.- Scheduler manifest — the four constraints checked before anything is written.
- Scheduler blob type — cron syntax, timezones, date windowing and schedule states, as the platform defines them.

