Skip to content

Calendar-driven agent orchestration

Chronary calendars are more than a place to record meetings. For an autonomous agent, a calendar is a scheduled work queue: each event is a planned unit of work with a start time, an end time, and attached metadata. Chronary emits lifecycle webhooks (event.started, event.ended) when those moments arrive and exposes a temporal context endpoint (GET /v1/calendars/:id/context) that answers “what should the agent be doing right now?” in one request.

This guide shows how to combine the two into a reliable run-loop.

  • Subscribe to event.started / event.ended to react to scheduled work
  • Fetch temporal context to recover state on agent startup
  • Build a small Node.js handler that dispatches by event type
  • Understand when to trust a webhook vs. poll /context
  • A Chronary account and API key (see the quickstart)
  • A public HTTPS endpoint that can receive webhook POSTs
┌────────────────────────┐
│ Your agent process │
└────────────┬───────────┘
cron fires event.started ┌─────────┴────────────┐
────────────────────────────► │ /webhooks/chronary │
│ (dispatch handler) │
cron fires event.ended └─────────┬────────────┘
────────────────────────────► │
│ on startup / reconnect
GET /v1/calendars/:id/context ──► current + next event

You subscribe once, receive pushes for every scheduled fire, and use GET /calendars/:id/context whenever you need to know the at-a-glance state of a calendar without replaying webhook history.

Step 1 — Create a calendar that drives the agent

Section titled “Step 1 — Create a calendar that drives the agent”
Terminal window
curl -X POST https://api.chronary.ai/v1/agents/agt_01H9X4a1b2c3d4/calendars \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Email triage queue",
"timezone": "America/New_York"
}'

Each event represents one scheduled task. Put anything the agent needs into metadata — a task type, a payload reference, retry counters, whatever your workflow requires.

Terminal window
curl -X POST https://api.chronary.ai/v1/calendars/cal_01H9X4p0q1r2s3/events \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Triage overnight inbox",
"start_time": "2026-04-17T08:00:00Z",
"end_time": "2026-04-17T08:15:00Z",
"status": "confirmed",
"metadata": {
"task_type": "email_triage",
"inbox": "support@acme.com",
"max_items": 50
}
}'

Step 3 — Subscribe to lifecycle webhooks

Section titled “Step 3 — Subscribe to lifecycle webhooks”

Register a webhook that listens for event.started and event.ended. Chronary fires these when the scheduled start/end time arrives — you don’t need a local scheduler.

Terminal window
curl -X POST https://api.chronary.ai/v1/webhooks \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://agent.acme.com/webhooks/chronary",
"events": ["event.started", "event.ended"]
}'

Step 4 — Split handlers by subscription, not by envelope

Section titled “Step 4 — Split handlers by subscription, not by envelope”

Chronary’s webhook body is the raw payload — there is no { type, data } envelope. The event type travels in the X-Chronary-Event-Type header (e.g. event.started), so a single endpoint can route on that header (or pass it to parseWebhookEvent from @chronary/schemas for a typed, discriminated result). If you’d rather keep each handler dead simple, register one webhook per event type, each pointed at its own path — then each path knows exactly what shape its body has. This guide uses the one-path-per-type approach.

Register two subscriptions, one for each lifecycle event:

Terminal window
curl -X POST https://api.chronary.ai/v1/webhooks \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://agent.acme.com/webhooks/chronary/event-started",
"events": ["event.started"]
}'
curl -X POST https://api.chronary.ai/v1/webhooks \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://agent.acme.com/webhooks/chronary/event-ended",
"events": ["event.ended"]
}'

Then the Node.js handler verifies the signature, parses the raw payload, and dispatches by route:

import express from 'express';
import { createHmac } from 'crypto';
const app = express();
// Read the body as raw bytes so the HMAC compares the exact bytes Chronary signed.
app.use(express.raw({ type: 'application/json' }));
function verifyChronarySignature(
secret: string,
timestamp: string,
body: Buffer,
signatureHeader: string,
): boolean {
const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
function requireFreshSignature(req: express.Request, res: express.Response, secret: string): boolean {
const timestamp = req.header('X-Timestamp') ?? '';
const signature = req.header('X-Signature') ?? '';
if (!timestamp || !signature) { res.status(401).send('missing signature'); return false; }
// X-Timestamp is Unix epoch seconds (decimal string). Mitigate replay by
// rejecting timestamps older than 5 minutes.
const ts = parseInt(timestamp, 10);
const ageSec = Math.floor(Date.now() / 1000) - ts;
if (!Number.isFinite(ageSec) || Math.abs(ageSec) > 5 * 60) {
res.status(401).send('stale timestamp'); return false;
}
if (!verifyChronarySignature(secret, timestamp, req.body as Buffer, signature)) {
res.status(401).send('bad signature'); return false;
}
return true;
}
app.post('/webhooks/chronary/event-started', async (req, res) => {
if (!requireFreshSignature(req, res, process.env.WEBHOOK_STARTED_SECRET!)) return;
// Acknowledge fast — do the work asynchronously.
res.status(200).send('ok');
// The body for event.started is { event_id, calendar_id, title, start_time, end_time }.
const payload = JSON.parse((req.body as Buffer).toString('utf8')) as {
event_id: string;
calendar_id: string;
title: string;
start_time: string;
end_time: string;
};
// Lifecycle payloads carry only IDs and timestamps — fetch the full row for metadata.
const event = await fetch(
`https://api.chronary.ai/v1/calendars/${payload.calendar_id}/events/${payload.event_id}`,
{ headers: { Authorization: `Bearer ${process.env.CHRONARY_KEY}` } },
).then((r) => r.json());
switch (event.metadata?.task_type) {
case 'email_triage':
return triageInbox(event.metadata.inbox, event.metadata.max_items);
case 'report_generation':
return buildReport(event.metadata.report_id);
default:
console.warn('unknown task_type', event.metadata?.task_type);
}
});
app.post('/webhooks/chronary/event-ended', async (req, res) => {
if (!requireFreshSignature(req, res, process.env.WEBHOOK_ENDED_SECRET!)) return;
res.status(200).send('ok');
const payload = JSON.parse((req.body as Buffer).toString('utf8')) as {
event_id: string;
calendar_id: string;
};
await finalize(payload.event_id);
});

A few things to note:

  • The body is the raw payload, not an envelope. There is no type, no data, no created_at key wrapping the payload. Each event type’s payload shape is documented in the webhooks API reference.
  • Signing: Chronary computes sha256=<hex> over `${X-Timestamp}.${body}`, using your webhook secret. Compare constant-time, reject on mismatch, reject stale X-Timestamp values.
  • X-Delivery-Id is the idempotency key — Chronary retries the same logical event with the same X-Delivery-Id, so dedup in your handler by that header.
  • Secrets per subscription: each POST /v1/webhooks call returns its own secret. Store them separately (shown as WEBHOOK_STARTED_SECRET / WEBHOOK_ENDED_SECRET above).
  • The lifecycle payload intentionally carries only identifiers and timestamps — fetch the event with GET /calendars/:id/events/:id to read metadata, description, and the rest.

Step 5 — Use temporal context on startup and reconnect

Section titled “Step 5 — Use temporal context on startup and reconnect”

When an agent boots, restarts, or loses connection, GET /v1/calendars/:id/context returns a single snapshot of what matters right now: the currently-running event, the next event, the last three that finished, and the next five within 24 hours.

Terminal window
curl https://api.chronary.ai/v1/calendars/cal_01H9X4p0q1r2s3/context \
-H "Authorization: Bearer chr_sk_your_key_here"

Sample response:

{
"calendar_id": "cal_01H9X4p0q1r2s3",
"now": "2026-04-17T08:07:42Z",
"agent_status": "working",
"current_event": {
"id": "evt_01H9X4t1u2v3w4",
"title": "Triage overnight inbox",
"startTime": "2026-04-17T08:00:00Z",
"endTime": "2026-04-17T08:15:00Z",
"status": "confirmed"
},
"next_event": {
"id": "evt_01H9X4x5y6z7a8",
"title": "Weekly metrics digest",
"startTime": "2026-04-17T09:00:00Z",
"endTime": "2026-04-17T09:30:00Z"
},
"recent_events": [
{ "id": "evt_01H9X4b9c0d1e2", "title": "Daily standup notes", "endTime": "2026-04-17T07:30:00Z" }
],
"upcoming": [
{ "id": "evt_01H9X4x5y6z7a8", "startTime": "2026-04-17T09:00:00Z" },
{ "id": "evt_01H9X4f3g4h5i6", "startTime": "2026-04-17T12:00:00Z" }
]
}
SituationUse
Fresh agent startup or restartGET /context to recover state
Real-time reaction to scheduled workevent.started / event.ended webhooks
Long-running agent that lost webhooks for a windowGET /context to reconcile, then resume on webhooks
Human-facing dashboardGET /context on a 30–60 s poll (it’s one round-trip)

Treat webhooks as the primary signal and /context as the idempotent recovery path — never build a tight polling loop that duplicates what webhooks already push.

Lifecycle fires run on a delay queue driven by the configured event times. Two precision numbers to know:

  • Expected fire precision: ~seconds, not sub-second. The scheduler computes delaySeconds = floor((fireTime - now) / 1000) when it enqueues the message. Queue consumers run shortly after the delay elapses.
  • Fire tolerance: 30 seconds. If the event’s start_time or end_time has shifted by more than 30 s between scheduling and delivery, the consumer drops the stale fire and waits for the next scheduled one.

A lifecycle maintenance cron runs every 6 hours (0 */6 * * *) to enqueue fires for events whose start/end time has just moved inside the Queue’s 23-hour retention window. In practice this means:

  • Events created more than ~23 hours in the future are picked up by the next 6-hour sweep, not scheduled immediately.
  • If you shorten an event’s lead time so it fires within the next 23 hours, expect scheduling within one sweep cycle.
  • Cancelled or deleted events stop firing — the consumer checks status === 'confirmed' and skips otherwise.

Don’t rely on lifecycle webhooks for sub-minute precision or for driving anything where a missed fire would be unrecoverable. Pair them with /context at startup so the agent can catch up from authoritative state.