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": "[email protected]",
"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 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:

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",
"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" }
]
}

| 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.

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.