One endpoint.
Every agent.
Stop letting your agents talk to Google directly. Route every calendar write through Openavail. We hold the only real token, enforce your priority rules, and log every decision.
POST /v1/availability/search sample
POST /v1/availability/search HTTP/1.1
Authorization: Bearer ak_01HX7QQM…
Content-Type: application/json
Idempotency-Key: my-agent:search:abc123
{
"owner_email": "alex@acme.com",
"calendar_type": "work",
"duration_minutes": 60,
"meeting_class": "Critical",
"earliest_start": "2026-06-10T09:00:00Z",
"latest_end": "2026-06-10T17:00:00Z",
"max_results": 50
}
← 200 OK
{
"requested_window": {
"start": "2026-06-10T09:00:00Z",
"end": "2026-06-10T17:00:00Z"
},
"candidates": [{ "start": "2026-06-10T10:00:00Z", "end": "2026-06-10T11:00:00Z", "risk": "free" }],
"candidate_limit": 50,
"available_candidate_count": 12,
"candidates_truncated": false,
"candidate_set": "exhaustive",
"resolved_calendar_type": "work",
"warnings": []
}From agent identity to booking verdict.
Give each agent a scoped key, send booking requests through Openavail, and read the decision before anything touches the calendar.
- 01
Register your agent
POST to /v1/agents with a display_name and the permission scopes you need. Openavail returns an agent identifier and an empty key set.
- 02
Create an API key
Call POST /v1/agents/{id}/keys. Copy the raw key — it is shown exactly once. Send it as Bearer ak_… on every request.
- 03
Send booking requests
First call GET /v1/meeting-classes/available to discover valid class names, priorities, and descriptions. Then call POST /v1/availability/search with the owner_email, window, meeting class, and duration. Openavail checks connected calendars and returns a capped candidate set without creating a hold. When the user is ready, reserve one with POST /v1/holds.
- 04
Read the decision
Every response is Accept, Reject, or CounterPropose. If you hold preempt permission and your priority wins, you get Preempt with displaced booking info.
Search first, reserve explicitly.
Search returns candidate slots without reserving capacity. Create a hold only when the user is ready, then confirm that hold into a deterministic booking verdict.
POST /v1/availability/search HTTP/1.1
Authorization: Bearer ak_01HX7QQM…
Content-Type: application/json
Idempotency-Key: my-agent:search:abc123
{
"owner_email": "alex@acme.com",
"calendar_type": "work",
"duration_minutes": 60,
"meeting_class": "Critical",
"earliest_start": "2026-06-10T09:00:00Z",
"latest_end": "2026-06-10T17:00:00Z",
"max_results": 50
}
← 200 OK
{
"requested_window": {
"start": "2026-06-10T09:00:00Z",
"end": "2026-06-10T17:00:00Z"
},
"candidates": [{ "start": "2026-06-10T10:00:00Z", "end": "2026-06-10T11:00:00Z", "risk": "free" }],
"candidate_limit": 50,
"available_candidate_count": 12,
"candidates_truncated": false,
"candidate_set": "exhaustive",
"resolved_calendar_type": "work",
"warnings": []
}Four verdicts, four actions.
Every booking request returns one of four decisions in the same HTTP response. The arbiter logic — priority order, tiebreakers, preempt rules — lives in arbitration. Here's how an agent reacts to each verdict.
AcceptNo conflict. Slot is free and all rules pass.
Booking is committed. Nothing more to do.
RejectSlot is blocked by a higher-priority booking, a hard rule (working hours, hard block, daily limit), or the agent lacks the preempt permission.
Inspect reason. NO_CAPACITY: slot taken — try a different time. WORKING_HOURS / OFF_DAY: outside working hours or a non-working day. SACRED_MEETING: immovable protected booking. MAX_DAILY_HOURS: daily meeting cap reached. Use next_available for the nearest free slot.
CounterProposeSlot is blocked, but other slots in the same hold window are still free.
Pick from alternatives[] and confirm one. Each entry includes start, end, and reason_code.
PreemptYour meeting class outranks an existing booking and your agent holds the preempt permission.
The displaced booking is moved to needs_reschedule. The displaced agent is notified via their pending_notifications envelope.
Before arbitration runs, start times are validated. If start is in the past the API returns 422 PAST_TIME immediately — no retry with the same value.
Only the access each agent needs.
read_freebusySearch for candidate times without reserving them.
Scheduling assistants, time-pickers.
read_eventsSee booking titles, descriptions, and attendees in Openavail responses.
Trusted context-aware agents.
create_holdsCreate short-lived candidate or window holds.
Multi-party schedulers, poll-based flows.
create_bookingsConfirm a held time and write the approved booking.
Sales bots, recruiting agents, focus-time schedulers.
preemptDisplace a lower-priority booking when a higher-priority one arrives.
Executive assistants, CEO meeting handlers.
cancel_human_eventsCancel bookings the calendar owner created directly in their calendar (not through an agent).
Personal assistant agents with full calendar control.
Permissions are scoped at agent registration and enforced on every request. Revoke individually or rotate the API key without removing the agent.
JSON over HTTPS. All endpoints versioned under /v1/.
Hosted remote MCP at https://mcp.openavail.com/mcp with OAuth login. Local stdio via @openavail/mcp remains available for API-key fallback.
Install, connect, check availability.
@openavail/sdk is a zero-dependency TypeScript client for Node 18+. No code generation, no OpenAPI toolchain — just import and call.
- ✓Typed errors — catch ArbitrationRejectedError, not status codes
- ✓Auto-injects idempotency keys — override when you need to
- ✓Write responses include pending_notifications inline (last 60 min)
- ✓Zero runtime dependencies — native fetch + crypto
first call · TypeScript
import { OpenavailClient } from '@openavail/sdk';
const client = new OpenavailClient({ apiKey: process.env.OPENAVAIL_API_KEY });
const { candidates } = await client.searchAvailability({
ownerEmail: 'alex@acme.com',
durationMinutes: 60,
meetingClass: 'Critical',
earliestStart: '2026-07-01T09:00:00Z',
latestEnd: '2026-07-01T17:00:00Z',
});
console.log('Candidate slots:', candidates);All datetimes are UTC.
Every start, end, earliestStart, and latestEnd field must be an ISO 8601 UTC string. The API never accepts timezone-offset times — convert before calling.
Call getOwnerContext() once at session start — it returns calendars (with IANA timezone), working hours, and valid meeting class names in a single round-trip.
timezone is null when the provider didn't return one — fall back to asking the user.
latestEnd is a deadline, not a start boundary: latestEnd is the latest a meeting may end, not start. For a 60-min meeting at 2 pm Berlin (12:00 UTC), set latestEnd: "13:00:00Z"— not 12:00:00Z.
timezone lookup · TypeScript
// 1. One call at session start — calendars, working hours, and
// valid meeting class names together.
const context = await client.getOwnerContext('owner@company.com');
const primary = context.calendars.find(c => c.is_primary);
const tz = primary?.timezone ?? 'UTC'; // null → fall back or ask the user
// 2. Convert "2pm local" → UTC before every booking call
// Use any IANA-aware library (e.g. luxon, date-fns-tz, Temporal)
const earliestStart = toUTC('2026-07-01T14:00:00', tz); // → '2026-07-01T12:00:00Z'
const latestEnd = toUTC('2026-07-01T15:00:00', tz); // → '2026-07-01T13:00:00Z'
// latestEnd is the latest a meeting may END, not start.
// 60-min meeting at 2pm Berlin (12:00 UTC) → latestEnd must be 13:00Z.
const { candidates } = await client.searchAvailability({
ownerEmail: 'owner@company.com',
durationMinutes: 60,
earliestStart, // earliest the meeting may begin
latestEnd, // latest the meeting may end (not start)
meetingClass: context.meetingClasses[0].name,
})Reserve first. Commit after.
searchAvailability returns candidates without reserving capacity. When your user picks a candidate, call createHold to reserve it inside Openavail, then confirm before the hold auto-expires.
Use hold → confirm when the user needs to review options before committing. Use createBooking (direct booking) when your agent already knows the exact slot — rule-driven focus blocks, for instance.
Search → hold → confirm
User picks from presented candidates. Interactive flows.
Direct booking
Agent decides autonomously. Rule-driven or time-blocked.
hold → confirm · TypeScript
// 1. Search availability — no reservation yet
const { candidates } = await client.searchAvailability({ ... });
// 2. Pick a candidate (e.g. present to user, await confirmation)
const chosen = candidates[0];
// 3. Reserve that candidate
const hold = await client.createHold({
ownerEmail: 'alex@acme.com',
meetingClass: 'Critical',
holdScope: 'candidate',
candidate: { start: chosen.start, end: chosen.end },
});
// 4. Confirm the hold → committed booking
const booking = await client.confirmHold({
holdId: hold.holdId,
start: chosen.start,
end: chosen.end,
title: 'Q3 roadmap sync',
attendees: [{ email: 'alex@acme.com' }],
});
console.log('Booking committed:', booking.bookingId);Skip the hold when you know the slot.
createBooking skips the hold step — one call, one decision. Arbitration still runs and can reject or CounterPropose. Use it when your agent decides the slot programmatically and doesn't need the user to choose.
direct booking · TypeScript
// Skip the hold when you already know the exact slot.
// Use this for rule-driven scheduling, not interactive pickers.
const booking = await client.createBooking({
ownerEmail: 'alex@acme.com',
meetingClass: 'focus_time',
start: '2026-07-02T09:00:00Z',
end: '2026-07-02T10:00:00Z',
title: 'Deep work block',
});The error carries the hint.
NoSlotsError is thrown by searchAvailability when no free slots exist. The error body is richer than most — read it directly instead of making a second call.
reasonCode
NO_FREE_SLOTS — calendar busy. DAILY_HOURS_LIMIT — daily cap. OFF_DAY — non-working day (e.g. Saturday). WORKING_HOURS — outside configured hours on a working day. HARD_BLOCK — recurring blocked period (e.g. lunch break).
nextAvailable
{ start, end } pointing at the nearest free slot beyond latestEnd. Searched by default (72h lookahead, max 72h — sized to bridge a full weekend). undefined when nothing is found within the window.
nextAvailableExceedsLookahead
true when slots exist beyond the lookahead window but were not returned. Retry with a larger nextAvailableLookaheadHours (max 72h). Only appears when you explicitly pass a value shorter than 72h.
no-slots handling · TypeScript
import { NoSlotsError } from '@openavail/sdk';
try {
const { candidates } = await client.searchAvailability({
ownerEmail: 'owner@company.com',
durationMinutes: 60,
meetingClass: 'Critical',
earliestStart, latestEnd,
// nextAvailableLookaheadHours defaults to 72h — covers full weekend gaps
});
} catch (err) {
if (err instanceof NoSlotsError) {
// err.reasonCode: 'NO_FREE_SLOTS' | 'DAILY_HOURS_LIMIT' | 'OFF_DAY' | 'WORKING_HOURS' | 'HARD_BLOCK'
// err.nextAvailable: { start, end } | undefined — nearest free slot within the lookahead window
// err.nextAvailableExceedsLookahead: true — slots exist beyond the window; retry with a wider lookahead
if (err.nextAvailable) {
// Nearest free slot found — suggest retrying there.
console.log('Try again starting at', err.nextAvailable.start);
} else if (err.nextAvailableExceedsLookahead) {
// Slots exist but are further out — increase nextAvailableLookaheadHours
console.log('Slots exist beyond the lookahead — widening search window.');
} else {
console.log('No upcoming slots found.');
}
}
}Catch the error. Use the alternatives.
ArbitrationRejectedError covers two distinct outcomes:
COUNTER_PROPOSED
Your slot is blocked but the hold window has other free slots. alternatives[] is populated — confirm with the same holdId and a different slot immediately, no new availability call needed.
REJECTED
Hard block: rule violation, higher-priority booking the agent can't preempt, or no preempt permission. alternatives[] is empty. Start a new searchAvailability with a different window.
Possible rejection reasons:
NO_CAPACITYSlot taken by equal or higher priority booking.WORKING_HOURSSlot falls outside configured working hours.OFF_DAYNon-working day (e.g. Saturday).SACRED_MEETINGImmovable protected booking — no priority can displace it.MAX_DAILY_HOURSOwner has reached their daily meeting cap.PERMISSION_DENIED_PREEMPTAgent lacks the preempt permission.Other typed errors follow the same pattern: NoSlotsError carries a nextAvailable hint, BookingNotFoundError means the hold expired, etc.
rejection handling · TypeScript
import { ArbitrationRejectedError } from '@openavail/sdk';
try {
const booking = await client.confirmHold({ holdId, start, end, title });
} catch (err) {
if (err instanceof ArbitrationRejectedError) {
if (err.alternatives.length > 0) {
// COUNTER_PROPOSED: your slot is blocked but the hold window has
// other free slots. alternatives[] are within the same holdId —
// confirm immediately without a new availability check.
const alt = err.alternatives[0];
const retry = await client.confirmHold({
holdId, // same hold, different slot
start: alt.start,
end: alt.end,
title,
});
} else {
// REJECTED: hard block (rule violation, higher-priority booking,
// no preempt permission). No alternatives — start a new
// searchAvailability call with a different window.
}
}
}Owner-scoped, not creator-scoped.
Any agent with create_bookings permission and owner-scope access can cancel any agent-created committed booking — not only the agent that originally made it. To cancel events the calendar owner created directly in their calendar, the agent also needs cancel_human_events.
The original booking agent is notified via pending_notifications on its next API response (inline for 60 min; always via getPendingNotifications()).
BookingNotCancellableError
Thrown when the booking status is not committed or needs_reschedule — e.g. already cancelled or in-flight.
cancel booking · TypeScript
// Any agent with create_bookings + owner scope can cancel — // not just the agent that originally created the booking. const result = await client.cancelBooking(bookingId); // The agent that originally made the booking receives a // booking.cancelled notification on its next API call. console.log(result.pendingNotifications);
Fresh inline. Full history via poll. Persist until acknowledged.
Openavail pushes notifications to the agent that owns the affected booking. Write responses include a pendingNotifications inline hint — but only for notifications created in the last 60 minutes, so stale events from a previous session never flood your current response. Call getPendingNotifications() at session start to retrieve the full unacked backlog. Notifications are not consumed by either path — they persist for 7 days until your agent calls ack-notifications.
booking.cancelled
Another agent (or the calendar owner) cancelled one of your bookings.
booking.displaced
A higher-priority booking preempted yours. The booking moves to needs_reschedule.
notifications · TypeScript
// Inline hint: only notifications from the last 60 minutes appear here.
const { bookingId, pendingNotifications } = await client.confirmHold({ ... });
// At session start, fetch the full unacked backlog (any age up to 7 days):
const allPending = await client.getPendingNotifications();
for (const n of allPending) {
if (n.kind === 'booking.displaced') {
console.log('Booking displaced — rescheduling:', n.payload);
}
}
// Ack all at once (up to 100 IDs per call).
// Un-acked notifications persist for 7 days; after that they are swept.
await client.ackNotifications(allPending.map((n) => n.id));One OAuth connection. All Openavail tools.
Add Openavail as a hosted remote HTTP MCP server at https://mcp.openavail.com/mcp. Claude Code and Codex can use the commands on the right; other remote MCP clients should use the same URL with OAuth/browser login. The hosted server exposes Openavail tools under Google Calendar MCP-compatible names, so agents that already target google-calendar-mcp work without code changes.
- →list-calendars, list-events, get-event, create-event, update-event, delete-event, search-events
- →get-agent-context, search-availability, confirm-hold, simulate, get-pending-notifications, ack-notifications
OAuth creates or reuses a client-specific backing agent such as Codex MCP with a safe approval-first grant. Owners can later adjust that agent's access in the dashboard. For clients without hosted OAuth MCP support, run @openavail/mcp locally and set OPENAVAIL_API_KEY in your application environment.
hosted MCP setup
# Hosted MCP with OAuth claude mcp add --transport http openavail \ https://mcp.openavail.com/mcp codex mcp add openavail \ --url https://mcp.openavail.com/mcp \ --oauth-client-id codex
Ready to connect your first agent?
Free tier includes 1 calendar, 1 agent, 50 bookings/month. See full pricing →
REST. MCP stdio. @openavail/sdk. Idempotent. Every decision logged.