Skip to main content
REST surface for the unified scheduler: one merged read/write view over two schedule sources: clawboo Routines (the team-task domain, fully managed) and the OpenClaw Gateway cron (the runtime-own-life domain, written through the Gateway’s own operator RPC). A read always succeeds and reports per-source degradation as data; a write routes to the owning source by id and surfaces the typed scheduling errors as precise status codes.
The two sources are never conflated. A Routine row schedules team work on a cadence, for any runtime class: a message to a team’s lead (a team routine) or a board task for one agent (an agent routine). A Gateway-cron row schedules an OpenClaw agent’s own standalone life. clawboo never registers team work into the Gateway cron, and never auto-creates own-life crons; the Routines view is an operator surface over them, not their owner.
Every record carries a composite id of the form <source>:<rawId> (clawboo-routine:<ledger-row-id> or openclaw-gateway-cron:<gateway-job-id>). The :id path segment on the mutation routes is URL-decoded and parsed back to its owning source; the write routes there. An id that matches no source returns 404. All POST/PATCH bodies are parsed by express.json({ limit: '2mb' }).

Routes


The ScheduleRecord shape

Every source projects its rows into one normalized record. This is the element type of the schedules[] array on GET, and the value returned (under schedule) on a successful create/update/pause/resume.
The two live sources are fixed:
There is no third source. Claude Code, Codex, Hermes, and clawboo-native have no live native scheduler; scheduling any of them is a clawboo Routine.

GET /api/schedules

The merged view. Fans read() across both sources and concatenates their records, then resolves each record’s teamName and agentName. A team routine has no stored agent, so it reports the team’s current lead (the agent its next fire would reach) as agentName and runtime. A source that fails or is disconnected does not fail the request; it contributes a degraded sources[] entry instead (a warm Gateway-cron cache is served stale; otherwise its rows are simply absent until reconnect). This route always returns 200.
  • Path/query params: none.
  • Request body: none.

Responses

200 OK: the merged records plus a per-source status array:
The Routines source reports { ok: true, degraded: false }. The Gateway-cron source reports { ok: false, degraded: true, reason: 'gateway_disconnected' } (no cache) or reason: 'stale_cache' (warm cache) when its operator connection is down.

Example


POST /api/schedules

Creates a schedule. The body is a ScheduleCreateSpec; the multiplexer routes the write to spec.source. Before the source is touched it enforces, in order: an observe-only source rejects with 403, and a team-task create aimed at a runtime-own-life source rejects with 422 (defense-in-depth; the Gateway-cron source refuses it too). The owning source then performs its own validation. For a clawboo-routine create: the cron spec is probed (an unparseable spec throws), a task template is built from label + taskTemplate + target (the template’s title defaults to the label, and its description is the instructions each fire sends), and the target is validated against the registry:
  • A team routine (target: 'team') needs a teamId naming a live, unarchived team. Its fires are posted into that team’s chat for the team’s lead, so the row stores no agent (agentId is ignored and reads back as ''), and it cannot bind a teamTaskId.
  • An agent routine (target: 'agent', the default) needs an agentId naming a live, unarchived agent, and is filed on that agent’s own team. A teamId is optional; when given it must match the agent’s team (null for an agent on no team).
A target that fails these checks is a 400 with code: "invalid_routine_target". A recurring spec bound to an existing teamTaskId is refused too (a bound task is claimable exactly once, so a recurring fire would park in error forever; bind only one-shot once@<iso> specs). Binding to a task already owned by another firing owner is the 409 de-dup refusal. For an openclaw-gateway-cron create, the source calls cron.add with the job’s payload (default { kind: 'agentTurn', message: label }) and the session target that payload kind requires: main for a systemEvent, isolated for anything else. The Gateway accepts a mismatched pair but then skips every fire, so the pairing is never left to the caller.
  • Path/query params: none.
  • Request body: a ScheduleCreateSpec. source, domain, and cronSpec are required, plus a teamId for a team routine or an agentId for anything else; the rest are optional:

Responses

201 Created: the schedule was registered:
400 Bad Request: the body is missing a required field, has an unknown source/domain/target, or fails a source-side validation. code is invalid_body for the shape check or a zod-rejected template, invalid_cron_spec for an unparseable cron spec, invalid_routine_target for a target that does not check out, or bound_recurring_schedule for a recurring spec bound to an existing task:
403 Forbidden: the target source is observe-only (no live source is observe-only today, but the gate exists):
404 Not Found: spec.source matches no registered source:
409 Conflict: the bound teamTaskId is already scheduled by a different firing owner. This is a data refusal; do not retry:
422 Unprocessable Entity: a domain: 'team-task' create was aimed at a runtime-own-life source (the Gateway cron):
503 Service Unavailable: the target is the Gateway-cron source and its operator connection is down:
500 Internal Server Error: any other throw:

Example


PATCH /api/schedules/:id

Pauses/resumes a schedule, or patches its cron spec, label, task template, payload, or target. The body is one of two shapes; an unrecognized body returns 400. The write routes to the source named in :id. For a Routine: pause sets the row to paused (disarmed, nextRunAt cleared) and is legal from idle, queued, or error; resume re-arms it to idle with a freshly computed nextRunAt and is legal from paused or error. Neither is legal while a fire is in flight (claimed or running), since that fire settles the row itself. A cron-spec patch recomputes nextRunAt only for an already-armed (idle) row. A patch that sets target, agentId, or teamId re-points the Routine, and the resulting target is validated exactly like a create (a target inside taskTemplate counts as one too). A teamTaskId inside a template patch is ignored: a Routine binds to a board task only at registration, where the firing-owner guard runs. For the Gateway cron: pause/resume map to cron.update { id, patch: { enabled } } (there is no separate enable/disable method), and a patch maps to cron.update { id, patch } with the changed fields (a changed payload carries its matching session target).
  • Path params: id (composite schedule id; URL-decoded; 404 on no-source-match).
  • Request body: exactly one of:

Responses

200 OK: the schedule was updated; the fresh record is returned (a Gateway-cron update may return schedule: null when the best-effort read-back can’t reload the job):
400 Bad Request: the body is neither a valid action nor a patch object, a patched cronSpec is unparseable, or a re-pointed target does not check out (invalid_routine_target):
403 Forbidden: the target source is observe-only:
404 Not Found: :id matches no source, or the id is unknown within its source:
409 Conflict: the requested pause/resume is illegal from the row’s current status (Routine state machine):
503 Service Unavailable: the Gateway-cron source’s operator connection is down:
500 Internal Server Error: any other throw:

Example


DELETE /api/schedules/:id

Removes a schedule. For a Routine it deletes the ledger row; for the Gateway cron it calls cron.remove. The write routes to the source named in :id.
  • Path params: id (composite schedule id; URL-decoded; 404 on no-source-match).
  • Request body: none.

Responses

200 OK: the schedule was removed:
403 Forbidden: the target source is observe-only:
404 Not Found: :id matches no source, or the id is unknown within its source:
503 Service Unavailable: the Gateway-cron source’s operator connection is down:
500 Internal Server Error: any other throw:

Example


POST /api/schedules/:id/run

Force-fires a schedule now. This is an enqueue-style acknowledgement, not a synchronous run: a Routine is moved to queued and the ticker, poked by the write, picks it up at once; the Gateway cron is told cron.run { id, mode: 'force' }. Only an idle Routine can be queued: a paused, errored, or already-running one returns 409. Completion is observed elsewhere (the Routine’s run history, the obs event log, or Gateway cron-run polling), not in this response. The write routes to the source named in :id.
  • Path params: id (composite schedule id; URL-decoded; 404 on no-source-match).
  • Request body: none.

Responses

202 Accepted: the fire was enqueued:
403 Forbidden: the target source is observe-only:
404 Not Found: :id matches no source, or the id is unknown within its source:
409 Conflict: a Routine could not be queued from its current status:
503 Service Unavailable: the Gateway-cron source’s operator connection is down:
500 Internal Server Error: any other throw:

Example


GET /api/schedules/:id/runs

A Routine’s recent fires, newest first, folded from the routine_* events in the obs event log (every one of them carries the Routine’s scheduledRunId). An agent routine’s fire carries the board task it created, resolved to that task’s current title and status. The Gateway keeps its own run history, so a Gateway-cron id returns an empty list.
  • Path params: id (composite schedule id; URL-decoded).
  • Query params: limit (optional, default 10, clamped to 1–50).
  • Request body: none.

Responses

200 OK:
A fire reads interrupted when it never recorded an outcome: the next fire began without one, or the Routine is no longer queued, claimed, or running. Both mean the server stopped during that fire. A team fire succeeded once the lead’s turn started; the team’s work then continues in the chat. 404 Not Found: :id is not a composite schedule id, or names no Routine:
500 Internal Server Error: any other throw:

Example


Error envelope

Every error response on these routes is the standard envelope plus a structural code: { error: string, code?: string }. The code is a stable, branch-on-able discriminant (never parse the message prose): A zod-rejected task template returns { error: "invalid task template", code: "invalid_body" }.

See also

Last modified on June 26, 2026