Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aegra.dev/llms.txt

Use this file to discover all available pages before exploring further.

Aegra supports scheduled runs via a background cron scheduler. Cron jobs are stored in PostgreSQL and fired by a polling loop that runs inside every server instance.

Prerequisites

Enable the scheduler (it is on by default):
CRON_ENABLED=true                  # default: true
CRON_POLL_INTERVAL_SECONDS=60      # default: 60
CRON_CLAIM_DURATION_SECONDS=300    # default: 300 (5 min lease)
CRON_MAX_PER_USER=100              # default: 100, 0 disables
CRON_ALLOW_SECONDS_SCHEDULE=false  # default: false
See the environment variables reference for all options.
Delivery semantics: at-least-once. When the scheduler claims a cron, the claim is held for CRON_CLAIM_DURATION_SECONDS. If a worker crashes between claiming and committing the run advance, another tick re-fires the cron once the claim expires. Make agent runs idempotent if duplicate firings are unacceptable.
Per-user cap. A single user can own at most CRON_MAX_PER_USER crons (default 100). Creating beyond the cap returns HTTP 429. Set CRON_MAX_PER_USER=0 to disable.
Sub-minute schedules. 6-field (seconds-first) schedules like */30 * * * * * are rejected unless CRON_ALLOW_SECONDS_SCHEDULE=true. They multiply scheduler load and DB writes per minute.

Creating a stateless cron

A stateless cron fires against a new thread on every occurrence:
import asyncio
from langgraph_sdk import get_client

async def main():
    client = get_client(url="http://localhost:2026")

    run = await client.crons.create(
        assistant_id="agent",
        schedule="0 9 * * *",   # every day at 09:00 UTC
        input={"messages": [{"role": "user", "content": "Good morning report"}]},
    )
    print(f"Cron ID: {run['cron_id']}")

asyncio.run(main())
Aegra accepts the graph ID directly in assistant_id for cron creation and resolves it to the default assistant for that graph at request time. By default, each stateless cron run deletes its ephemeral thread after the run completes, including the immediate first run that is triggered when the cron is created.
The snippets below assume you are inside an async def function with an initialized client.

Thread cleanup for stateless crons

Stateless crons create a fresh thread for every execution. Control what happens to that thread after the run finishes with on_run_completed:
  • "delete" (default) deletes the ephemeral thread automatically after the run completes.
  • "keep" preserves the thread so you can inspect its state or runs later.
run = await client.crons.create(
        assistant_id="agent",
        schedule="0 9 * * *",
        on_run_completed="keep",
        input={"messages": [{"role": "user", "content": "Daily report"}]},
)

print(run["cron_id"])
When you keep stateless cron threads, you are responsible for cleaning them up. For long-lived deployments, use thread TTL policies or a separate cleanup job if you do not want preserved threads to accumulate.

Creating a thread-bound cron

Bind a cron to a specific thread so every firing continues the same conversation:
thread = await client.threads.create()

run = await client.crons.create_for_thread(
    thread_id=thread["thread_id"],
    assistant_id="agent",
    schedule="0 8 * * 1",   # every Monday at 08:00 UTC
    input={"messages": [{"role": "user", "content": "Weekly digest"}]},
)
print(f"Cron ID: {run['cron_id']}")

Cron expression format

Aegra uses croniter for schedule parsing.

Standard 5-field format

┌──── minute (0-59)
│ ┌──── hour (0-23)
│ │ ┌──── day of month (1-31)
│ │ │ ┌──── month (1-12)
│ │ │ │ ┌──── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *
ExpressionMeaning
0 9 * * *Every day at 09:00
*/15 * * * *Every 15 minutes
0 9 * * 1Every Monday at 09:00
0 0 1 * *First day of every month at midnight

Seconds-level 6-field format

Prefix the standard expression with a seconds field for sub-minute scheduling:
*/30 * * * * *   # every 30 seconds

Timezone support

By default schedules use UTC. Pass an IANA timezone name to fire in local time:
run = await client.crons.create(
    assistant_id="agent",
    schedule="0 9 * * *",       # 09:00 in New York time
    timezone="America/New_York",
    input={"messages": [{"role": "user", "content": "Morning standup"}]},
)
The server stores the timezone alongside the cron record and converts to UTC internally. Passing an invalid timezone name results in an HTTP 422 error.
When updating a cron’s schedule without changing its timezone, the existing timezone is preserved automatically.

Listing and searching

# Search by assistant
crons = await client.crons.search(assistant_id="agent")

# Search by thread
crons = await client.crons.search(thread_id="<thread_id>")

# Count matching crons
total = await client.crons.count(assistant_id="agent")

Updating a cron

updated = await client.crons.update(
    cron_id="<cron_id>",
    schedule="0 10 * * *",  # new schedule
    enabled=False,           # pause without deleting
)
Supported update fields: schedule, enabled, input, config, metadata, timezone.

Disabling and re-enabling

# Pause
await client.crons.update(cron_id="<cron_id>", enabled=False)

# Resume
await client.crons.update(cron_id="<cron_id>", enabled=True)
Disabled crons are ignored by the scheduler but remain in the database. You can also create a cron in a paused state by passing enabled=False at creation. In that case the immediate first run is skipped and the response is the persisted Cron instead of a Run.

Deleting a cron

await client.crons.delete(cron_id="<cron_id>")
Deletion is permanent. When a cron is bound to a thread and that thread is deleted, the cron is removed atomically as well (ON DELETE CASCADE).

Limitations

  • Scheduled runs skip per-request auth handlers. Creating a cron runs the full auth chain (crons.createassistants.readthreads.read/search), but each scheduled firing executes as a background daemon under the cron owner’s identity and does not re-dispatch @auth.on(...) handlers. Put per-run authorization logic in the graph itself if it must apply to scheduled runs.
  • Cron metadata is not copied onto fired runs. metadata set on a cron is stored on the cron record for search and filtering, but is not propagated to the runs the cron creates.
  • Delivery is at-least-once. Under a crash between firing and advancing the schedule, a run can fire more than once for a single occurrence. Make scheduled work idempotent.