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.
What you’ll accomplish
Section titled “What you’ll accomplish”- Subscribe to
event.started/event.endedto 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
Prerequisites
Section titled “Prerequisites”- A Chronary account and API key (see the quickstart)
- A public HTTPS endpoint that can receive webhook POSTs
The pattern
Section titled “The pattern” ┌────────────────────────┐ │ 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 eventYou 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”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" }'import { Chronary } from '@chronary/sdk';
const client = new Chronary();
const calendar = await client.calendars.create({ agentId: 'agt_01H9X4a1b2c3d4', name: 'Email triage queue', timezone: 'America/New_York',});// calendar.id === 'cal_01H9X4p0q1r2s3'Step 2 — Drop events on the calendar
Section titled “Step 2 — Drop events on the calendar”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.
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 } }'await client.events.create('cal_01H9X4p0q1r2s3', { 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.
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"] }'const webhook = await client.webhooks.create({ url: 'https://agent.acme.com/webhooks/chronary', events: ['event.started', 'event.ended'],});// Save webhook.secret — you'll need it to verify signaturesStep 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:
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"] }'const startedHook = await client.webhooks.create({ url: 'https://agent.acme.com/webhooks/chronary/event-started', events: ['event.started'],});const endedHook = await client.webhooks.create({ url: 'https://agent.acme.com/webhooks/chronary/event-ended', events: ['event.ended'],});// Save each webhook's secret independently — each subscription has its own.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, nodata, nocreated_atkey 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 staleX-Timestampvalues. X-Delivery-Idis the idempotency key — Chronary retries the same logical event with the sameX-Delivery-Id, so dedup in your handler by that header.- Secrets per subscription: each
POST /v1/webhookscall returns its ownsecret. Store them separately (shown asWEBHOOK_STARTED_SECRET/WEBHOOK_ENDED_SECRETabove). - The lifecycle payload intentionally carries only identifiers and timestamps — fetch the event with
GET /calendars/:id/events/:idto readmetadata,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.
curl https://api.chronary.ai/v1/calendars/cal_01H9X4p0q1r2s3/context \ -H "Authorization: Bearer chr_sk_your_key_here"const context = await client.calendars.getContext('cal_01H9X4p0q1r2s3');if (context.current_event) { await resume(context.current_event);}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" } ]}Webhooks vs. polling /context
Section titled “Webhooks vs. polling /context”| Situation | Use |
|---|---|
| Fresh agent startup or restart | GET /context to recover state |
| Real-time reaction to scheduled work | event.started / event.ended webhooks |
| Long-running agent that lost webhooks for a window | GET /context to reconcile, then resume on webhooks |
| Human-facing dashboard | GET /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.
Caveats and precision
Section titled “Caveats and precision”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_timeorend_timehas 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.
What’s next?
Section titled “What’s next?”- Webhooks guide — signature verification, retry behavior, and event payload shapes
- Multi-agent scheduling and negotiation — coordinate several agents on a single time slot
- Events guide — the full CRUD surface for events