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": "[email protected]", "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', 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 and no X-Event-Type header. The event type is not on the wire; it’s fixed by the subscription. The cleanest way to handle multiple event types in one service is to register one webhook per event type, each pointed at its own path. Then each path knows exactly what shape its body has.
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", "start_time": "2026-04-17T08:00:00Z", "end_time": "2026-04-17T08:15:00Z", "status": "confirmed" }, "next_event": { "id": "evt_01H9X4x5y6z7a8", "title": "Weekly metrics digest", "start_time": "2026-04-17T09:00:00Z", "end_time": "2026-04-17T09:30:00Z" }, "recent_events": [ { "id": "evt_01H9X4b9c0d1e2", "title": "Daily standup notes", "end_time": "2026-04-17T07:30:00Z" } ], "upcoming": [ { "id": "evt_01H9X4x5y6z7a8", "start_time": "2026-04-17T09:00:00Z" }, { "id": "evt_01H9X4f3g4h5i6", "start_time": "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