Cron / scheduled runs
Schedules that fire a run on a cadence — LangGraph Platform's Crons resource, served by skein-js and driven by the official @langchain/langgraph-sdk client.
Compatibility note: crons are not part of the open Agent Protocol spec — its
openapi.jsonhas no cron paths. They are a LangGraph Platform / LangSmith Deployment extension, and a paid-tier one. The OSS@langchain/langgraph-apiserver registers the routes but every handler throws500 Not implemented, and itsGET /inforeportscrons: false. skein implements them against the LangSmith Deployment OpenAPI spec plus the SDK's TypeScript types — the same oracle the rest of the wire surface uses (reuse.md).
A cron is a cadence, not a delay. To run something once, a little later, pass after_seconds on an ordinary run create — no cron row, nothing to clean up afterwards. Reach for a cron when the run should keep happening.
Quick start
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: "http://localhost:2024" });
// Every weekday at 09:00 New York time, on a fresh thread each fire.
const cron = await client.crons.create("agent", {
schedule: "0 9 * * 1-5",
timezone: "America/New_York",
input: { messages: [{ role: "user", content: "Summarize yesterday's issues." }] },
});
// What has it produced? A stateless cron makes a thread per fire, each tagged with the cron id.
const threads = await client.threads.search({ metadata: { cron_id: cron.cron_id } });
const runs = await client.runs.list(threads[0].thread_id);
await client.crons.update(cron.cron_id, { enabled: false }); // pause
await client.crons.delete(cron.cron_id);assistant_id accepts a UUID or a graph name from your langgraph.json — a graph name resolves to the assistant skein auto-registers for it.
Stateless vs thread crons
Stateless (crons.create) | Thread (crons.createForThread) | |
|---|---|---|
| Thread | A fresh one per fire | The one you named, reused every fire |
| State | Starts clean each time | Accumulates across fires |
| Default cleanup | on_run_completed: "delete" | n/a — the thread is yours |
| Concurrency | Cannot collide (each fire has its own thread) | multitask_strategy defaults to enqueue |
Use a stateless cron for independent jobs (a nightly digest). Use a thread cron when each run should see what the previous ones did (a long-running monitor).
on_run_completeddefaults to"delete", matching LangGraph — and deliberately unlike skein's ownon_completiondefault of"keep"for one-off stateless runs. A schedule is the case where the difference bites: a five-minutely cron keeping every thread accrues over a hundred thousand of them a year, with nobody watching. Passon_run_completed: "keep"if you want them, and give the checkpointer a TTL.
Schedule format
Standard 5-field cron only — minute, hour, day-of-month, month, day-of-week:
┌───────── minute (0-59)
│ ┌─────── hour (0-23)
│ │ ┌───── day of month (1-31)
│ │ │ ┌─── month (1-12)
│ │ │ │ ┌─ day of week (0-6, Sunday = 0)
│ │ │ │ │
0 9 * * 1-5Six-field (seconds-first) expressions, @daily-style nicknames, and L/? are rejected with a 422, matching LangGraph's "cron must be a standard 5-field expression". skein's parser (croner) would accept them, so the five-field rule is enforced before the expression reaches it: silently running 0 0 9 * * * at a different time than the author's crontab says is worse than refusing it.
timezone is an optional IANA name (America/New_York); absent or null means UTC. DST is handled: an occurrence in a spring-forward gap shifts forward into the same day rather than being skipped, and a repeated fall-back hour fires once.
The finest resolution is one minute. Sub-minute schedules are not expressible and are a non-goal.
Endpoints
| Method | Path | Notes |
|---|---|---|
POST | /runs/crons | Stateless cron → Cron |
POST | /threads/{thread_id}/runs/crons | Thread cron → Cron |
POST | /runs/crons/search | → Cron[], x-pagination-total |
POST | /runs/crons/count | → a bare integer |
GET | /runs/crons/{cron_id} | → Cron |
PATCH | /runs/crons/{cron_id} | → Cron |
DELETE | /runs/crons/{cron_id} | → 200 with a JSON body, not 204 |
All seven answer the full Cron object. Two response shapes are deliberately unusual because the official SDK requires them: count returns a bare integer rather than { count }, and DELETE returns 200 with a body (the SDK skips response.json() only for 202 and 204, so an empty 200 makes it throw).
search accepts assistant_id, thread_id, enabled, metadata (subset match), limit, offset, sort_by, sort_order, and select. select is validated and then ignored — skein always returns the whole row.
Set "http": { "disable_crons": true } in langgraph.json to remove the resource. That also stops the scheduler, so a disabled deployment does not keep firing schedules nobody can see or cancel.
Semantics
Pausing. enabled: false stops a cron without deleting it; the row stays readable and next_run_date becomes null. Re-enabling recomputes it.
next_run_date === null means dormant — disabled, past end_time, or an unreachable expression (0 0 30 2 *). It is the same field the scheduler indexes on, so the wire answer and the firing behaviour can never disagree.
end_time is inclusive: an occurrence falling exactly on it still fires. On PATCH it is tri-state — omit to leave it, send null to clear it, send a value to set it. Same for timezone.
Metadata merges on PATCH rather than being replaced. The run payload is replaced wholesale when a patch touches any run field, because a half-replaced request (new input, stale config) is not a request anyone asked for.
Catch-up. If the server is down when occurrences pass, the cron fires once on return and resyncs to the next future occurrence. It does not backfill: replaying an hour of a five-minutely schedule is twelve times the model spend on near-identical inputs, and on a thread cron they serialize so the catch-up becomes the outage. This matches APScheduler's coalesce default; there is no knob.
Traceability. Every fired run — and every thread a stateless cron creates — carries cron_id in its metadata. For a stateless cron that makes client.threads.search({ metadata: { cron_id } }) the way in, since each fire has its own thread; for a thread cron the thread is already known, so client.runs.list(thread_id) lists the runs and each carries the cron_id that produced it. (The SDK has no runs.search — runs are always addressed through their thread.)
Failures. If a fire fails (a deleted assistant, a store error) the cron is logged at error, counted, and left enabled — the next occurrence is the retry. skein never auto-disables a schedule: turning a thirty-second rolling-deploy blip into a silently stopped cron needing a human is the worse failure.
Deleting a thread deletes its thread crons, by foreign key. A cron cannot outlive the thread it runs on.
Driver requirements
Crons work on every store/queue combination, including skein dev on memory with no Docker.
| Store | Queue | Crons | Posture |
|---|---|---|---|
| memory | memory | Work; snapshotted to .skein/ by skein dev | Development |
| postgres | memory | Durable; single-instance delivery | Fine for small production |
| postgres | redis | Durable; at-least-once delivery; multi-instance | Recommended |
| memory | redis | Work, but lost on restart outside skein dev | Warned about at startup |
The scheduler logs one warning at startup on a non-durable store. A schedule that quietly stops is worse than one that was never created, and an embedded memory store in production loses every cron on every deploy.
How it works
Every instance runs a ticker. Every 30 seconds (SKEIN_CRON_TICK_MS) it asks the store for enabled crons whose next_run_date has arrived, and for each one:
- Computes the next occurrence, from the later of the stored date and now — so a cron that fell behind lands in the future in one step rather than replaying the backlog.
- Claims it with a single-row conditional
UPDATEon the primary key, guarded by a claim token the store bumps on every write. If three instances see the same occurrence, exactly one wins; the others skip. No leader election, no distributed lock, no Redis. - Commits the claim and the
pendingrun row in one transaction, then enqueues the run.
That third step is a transactional outbox, and it is what makes delivery durable. Advancing without the run would silently skip the occurrence if the instance died in between; creating the run first would re-fire it. Committed together, the worst case is a pending run that never reached the queue — which the next tick re-enqueues. Because enqueue is idempotent (keyed on the run id) and the worker skips runs already terminal, delivery is at-least-once and execution exactly-once.
The recovery sweep looks only at runs a cron produced. A bare pending is not enough to conclude a run is waiting for a worker: an inline wait/stream run is written pending too and only becomes running once it acquires its thread's lock, so one queued behind a long peer would look identical while its caller waits to execute it in-process. Handing that to a worker would run the graph twice.
The run itself then goes through the ordinary run queue — on Redis that is BullMQ, so cron-fired runs get its retries, backoff, and stalled-job crash recovery like any other background run.
Schedule state lives in the store rather than in the queue, because enabled, sort_by=next_run_date, filtered count, and metadata search are all things a queue cannot serve — and because a schedule that a FLUSHALL can erase while the API still reports it as enabled is worse than no schedule.
Operating a scheduler
The one metric to alert on is cron lag — how overdue the most overdue enabled cron is. Healthy, it stays below one tick; if the scheduler dies it climbs without bound. It is the only signal that distinguishes "nothing is due" from "nothing is running the crons". Each tick reports it, along with counts of fired / claims lost / failed / re-enqueued runs.
Claims lost are not errors: on N instances, N−1 lose every occurrence by design.
| Setting | Default | Notes |
|---|---|---|
SKEIN_CRON_TICK_MS | 30000 | Lower does not help below one minute; higher trades lateness. |
Serverless caveat. Nothing fires if no process is running. On Cloud Run that means --no-cpu-throttling and --min-instances=1 — see deploy-cloud-run.md. Scale-to-zero and cron are incompatible, and no queue changes that.
Authentication
Crons are a first-class auth resource, so @auth.on.crons.create, .read, .update, .delete, and .search all work — matching the SDK's own Auth class.
If you have not registered a cron handler, crons inherit your threads scoping. This matters, because @auth.on callbacks are matched by exact event key: a deployment that wrote .on("threads", …) has no callback named crons, and without the fallback the whole resource would be served unscoped. Since a schedule is really "runs on a thread, later", threads is both the safe default and the honest one — cron routes fall back to it, and create specifically falls back to threads:create_run.
Attaching a schedule to a thread is always authorized as threads:create_run, separately from the cron handler. A cron writes into a thread indefinitely, so it is gated by thread ownership even when your cron handler is more permissive than your thread handler. A thread you cannot read answers 404, exactly as it does everywhere else.
A cron fires with no HTTP request behind it, so skein remembers the creating principal (beside the row, never on the wire) and replays it into every run. The ownership filters are re-derived from your @auth.on handler at fire time rather than frozen at create time, which means:
- Editing your auth handler applies to existing crons immediately.
- Revoking a principal stops their crons producing runs; re-granting resumes them.
- A stateless cron's per-fire thread is owner-stamped, so the user who created the cron can actually find its results in their own
POST /threads/search.
See agent-protocol.md for the request lifecycle.