Scheduled jobs
GET /v1/scheduled-jobs returns three kinds of job in one list, not just user-created ones:
kind |
Origin | Editable via this API? |
|---|---|---|
system |
Code-defined singleton jobs (housekeeping sweeps, etc). | enabled/cron only, via PATCH — never created/deleted here. |
agent |
Code-defined jobs that fire an agent run (DISCRETE_JOBS). |
enabled/cron only, via PATCH — never created/deleted here. |
custom |
Portal-created jobs: {name, cron, agent_slug, brief}. |
Full CRUD. |
Only custom jobs are POST/DELETE-able. Use them to wake an agent on a schedule — a daily PR-status DM, a weekly pipeline kickoff, and so on. The runtime owns the cron evaluator; the CP is a thin proxy.
The scheduled job object
A system/agent row:
{
"id": "kaneko_daily_digest",
"kind": "agent",
"agent": "kaneko",
"schedule": "hour=9 minute=0",
"enabled": true,
"next_run_at": "2026-05-13T09:00:00+00:00",
"description": "Summarise yesterday's activity to Discord."
}
A custom row adds brief, name, model, reasoning:
{
"id": "custom-14",
"kind": "custom",
"agent": "hachimi",
"schedule": "0 9 * * *",
"enabled": true,
"next_run_at": "2026-05-13T09:00:00+00:00",
"description": "Read open PRs across forjio family. Summarise red CI to Disc",
"brief": "Read open PRs across forjio family. Summarise red CI to Discord.",
"name": "Daily PR status",
"model": "auto",
"reasoning": null
}
| Field | Type | Notes |
|---|---|---|
id |
string | No sj_ prefix. Built-in (system/agent) jobs use their bare code-defined job id (e.g. kaneko_daily_digest). Custom jobs use custom-<row id>, where <row id> is a plain autoincrement integer. |
kind |
enum | system, agent, or custom — see the table above. |
agent |
string | null | The agent slug the job fires, when applicable. null for pure system jobs. |
schedule |
string | For custom jobs, the raw cron expression. For built-ins, either an operator override cron string or a human-readable rendering of the code default's fields (there is no clean "get the cron string back" accessor for a code-defined apscheduler trigger). |
enabled |
bool | If false, this job doesn't fire on its next tick. |
next_run_at |
timestamp | null | Pre-computed next firing time in UTC. null if it can't be computed. |
description |
string | Built-ins: the job function's docstring first line. Custom: brief truncated to 200 chars. |
brief |
string | Custom-only. The full, untruncated prompt sent as the run's message — use this field to edit, not description. |
name |
string | Custom-only. |
model |
string | Custom-only. A pinned model id, or "auto" (resolver decides — the default). |
reasoning |
bool | null | Custom-only. true/false pins the reasoning rider; null means "auto" (derived from the resolved model's capability). |
There is no
tzfield. Cron is always evaluated in UTC —CronTrigger.from_crontab(..., timezone="UTC")is hardcoded, with no per-job timezone override exposed anywhere in this API. Convert your desired local time to UTC yourself. There is also nolast_run_at/last_run_idfield on this endpoint.
Endpoints
List jobs
GET /v1/scheduled-jobs
Powers Dashboard → Scheduled jobs. Meta: {"total", "heartbeat_total", "heartbeat_enabled"} — the last two are a summary tile linking out to the separate heartbeats page.
const { data } = await catentio.scheduledJobs.list();
curl 'https://catent.io/api/v1/cp/v1/scheduled-jobs' \
-H "Cookie: catentio_session=<session cookie set at login>"
Create a job
POST /v1/scheduled-jobs
Always creates a custom job. Body:
{
"name": "Daily PR status",
"agent_slug": "hachimi",
"cron": "0 9 * * *",
"brief": "Read open PRs across forjio family. Summarise red CI to Discord.",
"enabled": true
}
name, cron, agent_slug, and brief are required (400 if any is missing/blank — note the field is agent_slug, not agent). cron must parse as a standard 5-field expression (validated against UTC; there's no separate timezone to get wrong). enabled defaults to true. Optional model (a model id, or "auto"/omitted) and reasoning (true/false, or omitted for auto) pin per-tick overrides.
agent_slugisn't checked against the real agent registry. A typo'd slug is accepted at create time and only fails later, when the job actually fires and tries to dispatch a run.
Response: {"ok": true, "id": "custom-14", "name", "cron", "agent_slug", "brief", "enabled", "model", "reasoning", "requires_scheduler_restart": false}.
Update a job
PATCH /v1/scheduled-jobs/{job_id}
Partial update. Patchable fields depend on the job:
- Any job (
system/agent/custom):enabled,cron. - Custom jobs only:
brief,name,model,reasoning.model/reasoninguse omit-vs-null semantics — a key that's simply absent leaves the current value unchanged, while an explicitnullclears it back to"auto".
The CP rejects an empty body with 400 provide at least one of: enabled, cron, brief, name, model, reasoning.
await catentio.scheduledJobs.update('custom-14', { enabled: false });
curl -X PATCH 'https://catent.io/api/v1/cp/v1/scheduled-jobs/custom-14' \
-H "Cookie: catentio_session=<session cookie set at login>" \
-H "Content-Type: application/json" \
-d '{"enabled":false}'
Delete a job
DELETE /v1/scheduled-jobs/{job_id}
Only custom jobs are deletable. A system/agent job id returns 400 — disable it via PATCH {"enabled": false} instead; it can't be removed through this API at all.
Attachments
POST /v1/scheduled-jobs/{job_id}/attachments (multipart/form-data)
GET /v1/scheduled-jobs/{job_id}/attachments
Custom jobs only (400 on a built-in job id). Upload reference files the agent should read when the job runs. Max 10 files, 25 MB total per upload. Same shape as project attachments — see Projects.
Cron evaluator
The runtime runs a single cron tick every minute, in UTC. On each tick it fires every enabled job whose schedule matches, computing the next next_run_at. A job that's "overdue" (e.g., the runtime was restarted across multiple firing windows) runs once at the next tick — missed windows are not back-filled.
Each scheduled trigger creates a normal run with transport = "cron". You can inspect them via GET /v1/runs?transport=cron just like any other run.
Failure handling
A scheduled-job run that fails doesn't retry automatically. The job stays scheduled and tries again on the next cron tick.
There is no job-level failure notification. But a scheduled job's runs are ordinary runs, so an outbound webhook on run.failed will fire for them — see Webhooks (outbound). To find out that a scheduled run failed, poll: list runs with transport=cron and check their status, or poll GET /v1/events for the pipeline timeline.
Errors
| Status | When |
|---|---|
| 400 | empty_patch-equivalent (provide at least one of: ...); missing name/cron/agent_slug/brief on create; malformed cron/enabled/brief/name; deleting or otherwise mutating a field a built-in job doesn't support. |
| 404 | Job id doesn't exist. |
| 409 | A custom job with that name already exists (unique per workspace). |
| 502 | CP couldn't reach the runtime. |
There is no 422 on this resource — cron/validation failures come back as 400, and there is no invalid_tz/unknown_agent error code (there's no tz field, and agent_slug isn't validated against the agent registry — see the callout above).
Events
Scheduled-job CRUD isn't emitted as events. Triggered runs write standard run events (run_started, run_completed, run_failed) with transport = "cron". Read them via GET /v1/events — Catentio does not push them to your endpoints.
Heartbeats vs. scheduled jobs
Catentio has a separate /v1/heartbeats namespace for fixed-interval triggers (e.g., "fire every 5 minutes"). They differ from scheduled jobs:
| Scheduled jobs | Heartbeats | |
|---|---|---|
| Granularity | Cron expression, UTC | Fixed interval (seconds) |
| Use case | "Daily at 9am UTC" | "Every 5 minutes, irrespective of clock" |
| Drift | Locked to cron | Drifts with runtime restarts |
For most "wake an agent on a schedule" use cases scheduled jobs are the right pick. Heartbeats are for low-jitter monitoring loops.