Write a provider

Add a calendar calsdk does not support yet, with the same contract, helpers and tests as the official providers.

A provider is a function that returns a plain object implementing CalendarProviderV1 from @cal-sdk/provider. Its job is narrow on purpose: translate calsdk's requests into one API's requests, and the responses back into calsdk's shapes. What would be the same for every calendar lives in @cal-sdk/core instead (validation, retries, pagination loops, the free/busy fallback and warning logging), so you only write the part that's specific to your API.

npm install @cal-sdk/provider @cal-sdk/provider-utils zod
npm install -D @cal-sdk/provider-tests

The official providers are built on exactly these packages, so yours gets the same tools.

The contract

Here's an outline of the type. The package has the full option and result types for each method.

type CalendarProviderV1<Provider extends string, Types extends ProviderTypesV1> = {
  readonly specificationVersion: 'v1';
  readonly provider: Provider; // also the key in providerOptions and providerMetadata
  readonly capabilities: CalendarCapabilitiesV1;

  doListCalendars(options): PromiseLike<{ calendars; warnings }>;
  doGetEvent(options): PromiseLike<{ event; warnings }>;
  doListEvents(options): PromiseLike<{ events; nextPageToken?; warnings }>;
  doCreateEvent(options): PromiseLike<{ event; warnings }>;
  doUpdateEvent(options): PromiseLike<{ event; warnings }>;
  doDeleteEvent(options): PromiseLike<{ warnings }>;

  doFreeBusy?(options): PromiseLike<{ calendars; warnings }>; // calsdk falls back to doListEvents
  doSyncEvents?(options): PromiseLike<{ changes; nextPageToken?; nextCursor?; warnings }>;
};

The types catch shape mistakes. These rules cover what they can't:

  • Declare capabilities honestly. calsdk picks fallbacks from them, and the conformance suite only tests what you declare as true.
  • Resolve 'primary' to the user's default calendar.
  • Keep progress in cursors. Calls may run at once from many places, so never keep sync position or other per-call state in the provider. Caching setup that doesn't change, such as CalDAV's server discovery, is fine.
  • Return exact times. Timed dateTimes carry an offset, and timeZone is an IANA name, so every reader gets the same instant.
  • Map errors to calsdk's classes, so apps can catch NotFoundError or ConflictError the same way for every calendar. mapAPICallError does this for HTTP status codes.
  • Never save something else. If the API can't store what was asked, throw UnsupportedFunctionalityError: a meeting on the wrong day is worse than an error. If you drop a harmless part, such as a color, return an unsupported warning instead.
  • Pass abortSignal and headers to every request, so callers can cancel a call and add their own headers.
  • Read only your own key in providerOptions. Apps may pass options for several providers at once.

A minimal provider

acme.ts
import {
  type CalendarProviderV1,
  type EventV1,
  UnsupportedFunctionalityError,
} from '@cal-sdk/provider';
import {
  type AccessToken,
  createJsonFetcher,
  type FetchFunction,
  mapAPICallError,
  urlPath,
} from '@cal-sdk/provider-utils';
import { z } from 'zod';

const eventSchema = z.object({
  id: z.string(),
  name: z.string(),
  starts: z.string(),
  ends: z.string(),
  zone: z.string(),
});

function fromAcme(raw: z.infer<typeof eventSchema>, calendarId: string): EventV1 {
  return {
    id: raw.id,
    calendarId,
    kind: 'single',
    title: raw.name,
    start: { dateTime: raw.starts, timeZone: raw.zone },
    end: { dateTime: raw.ends, timeZone: raw.zone },
    status: 'confirmed',
    showAs: 'busy',
  };
}

type AcmeSettings = { accessToken: AccessToken; fetch?: FetchFunction };

export function acme(settings: AcmeSettings): CalendarProviderV1<'acme'> {
  const request = createJsonFetcher({ baseURL: 'https://api.acme.example/v1', ...settings });

  return {
    specificationVersion: 'v1',
    provider: 'acme',
    capabilities: {
      events: {
        details: true,
        allDay: false,
        recurrence: false,
        recurrenceScopeThisAndFollowing: false,
        serverSideExpansion: false,
        attendees: false,
        conference: false,
        reminders: false,
        color: false,
        etag: false,
        search: false,
      },
      freeBusy: false,
      sync: false,
      webhooks: false,
    },

    async doGetEvent({ calendarId, eventId, abortSignal, headers }) {
      try {
        const { value } = await request({
          method: 'GET',
          path: urlPath`/calendars/${calendarId}/events/${eventId}`,
          schema: eventSchema,
          abortSignal,
          headers,
        });
        return { event: fromAcme(value, calendarId), warnings: [] };
      } catch (error) {
        throw mapAPICallError(error, { provider: 'acme', resource: { type: 'event', id: eventId } });
      }
    },

    async doCreateEvent({ calendarId, event }) {
      if (event.recurrence) {
        throw new UnsupportedFunctionalityError({ provider: 'acme', functionality: 'recurring events' });
      }
      // ...
    },

    // doListCalendars, doListEvents, doUpdateEvent, doDeleteEvent
  };
}

createJsonFetcher sends the bearer token, resolving it per request, and validates responses with any Standard Schema library such as zod. It never follows redirects and never puts request headers in its errors, so the token can't leak to another host or into your logs. urlPath encodes ids, which can contain @, / and #.

Type your options and metadata

Declare what each operation reads from providerOptions, and what you put in providerMetadata, and calsdk type-checks them against the provider passed as calendar:

type AcmeTypes = {
  options: { createEvent: { notifyRoom?: boolean } };
  eventMetadata: { roomId?: string };
};

export function acme(settings: AcmeSettings): CalendarProviderV1<'acme', AcmeTypes> {
  // ...
}

Validate the options at runtime too, since callers may not use TypeScript. parseProviderOptions reads your key, throws InvalidArgumentError for wrong values and warns about unknown keys:

const { options, warnings } = await parseProviderOptions({
  provider: 'acme',
  providerOptions,
  schema: z.object({ notifyRoom: z.boolean().optional() }),
});

Next

When you're unsure what a method should return, read the mock provider. It implements every operation in memory, with no API in the way, and is the clearest reference.

On this page