skylight-client
v0.1.0
Published
Unofficial TypeScript/JavaScript client for the Skylight Calendar API (app.ourskylight.com)
Maintainers
Readme
skylight-client
Unofficial TypeScript/JavaScript client for the Skylight Calendar API (app.ourskylight.com).
Skylight has no official public API. This library wraps the reverse-engineered endpoints the Skylight mobile app uses — the same ones documented by TheEagleByte/skylight-api and implemented in Go by sebrandon1/go-skylight.
⚠️ Disclaimer: This project is not affiliated with or endorsed by Skylight. It talks to undocumented endpoints that may change or break without notice. Use at your own risk.
- Zero runtime dependencies — uses native
fetch(Node ≥ 18.14, Deno, Bun, edge runtimes) - Fully typed, ESM + CJS
- All four auth flows: email/password, rotating OAuth refresh token, bearer access token, legacy Basic token
- Automatic token refresh on 401 and retry with backoff on 429/5xx
Install
npm i skylight-clientQuick start
import { SkylightClient } from 'skylight-client';
const sky = new SkylightClient({
email: process.env.SKYLIGHT_EMAIL!,
password: process.env.SKYLIGHT_PASSWORD!,
});
const [frame] = await sky.frames.list();
const events = await sky.events.list(frame.id, {
dateMin: '2026-07-01',
dateMax: '2026-08-01', // dateMax is exclusive — day AFTER the last day you want
timezone: 'America/Chicago',
});
await sky.events.create(frame.id, {
summary: 'Dentist appointment',
// always include the UTC offset — offset-less timestamps are treated as UTC
startsAt: '2026-07-20T15:00:00-05:00',
endsAt: '2026-07-20T16:00:00-05:00',
timezone: 'America/Chicago',
categoryId: events[0]?.categoryId,
});Authentication
Pass one of four credential shapes to the constructor:
// 1. Email + password (headless OAuth login, same flow as the mobile app)
new SkylightClient({ email, password });
// 2. OAuth refresh token (recommended for long-lived integrations)
new SkylightClient({ refreshToken, fingerprint });
// 3. Pre-captured Bearer access token
new SkylightClient({ accessToken });
// 4. Legacy user-id + token pair (sent as Basic auth)
new SkylightClient({ userId, token });Persisting tokens (important!)
Skylight rotates the refresh token on every use. If you authenticate with
refreshToken or email/password, register an onTokenRefresh callback and
persist the new tokens — otherwise the next process start will fail:
import { SkylightClient } from 'skylight-client';
import { readFile, writeFile } from 'node:fs/promises';
const saved = JSON.parse(await readFile('tokens.json', 'utf8'));
const sky = new SkylightClient(
{ refreshToken: saved.refreshToken, fingerprint: saved.fingerprint },
{
onTokenRefresh: async (tokens) => {
await writeFile(
'tokens.json',
JSON.stringify({ ...tokens, fingerprint: sky.fingerprint }),
);
},
},
);The fingerprint is a stable UUID identifying your "device" to Skylight.
Persist it alongside the refresh token (they're bound together). One is
generated automatically if you don't provide one — read it from
sky.fingerprint.
You can also drive the auth flows directly:
import { loginWithPassword, refreshOAuthToken, generateFingerprint } from 'skylight-client';
const fingerprint = generateFingerprint();
const tokens = await loginWithPassword(email, password, fingerprint);
// → { accessToken, refreshToken, expiresIn, tokenType }API
Everything is scoped to a frame (your Skylight device/household). Get frame
ids from sky.frames.list().
Frames
await sky.frames.list(); // Frame[]
await sky.frames.get(frameId); // Frame
await sky.frames.devices(frameId); // Device[]
await sky.frames.sourceCalendars(frameId); // linked Google/iCloud/... calendarsCalendar events
await sky.events.list(frameId, { dateMin, dateMax, timezone }); // dateMax is exclusive
await sky.events.create(frameId, { summary, startsAt, endsAt, allDay, color, location, timezone, categoryId });
await sky.events.update(frameId, eventId, { summary: 'New title' });
await sky.events.delete(frameId, eventId);Events mirrored from source calendars have editable: false and can only be
changed in their origin calendar (Google, iCloud, …). Timestamps without a UTC
offset are interpreted as UTC — include the offset or set timezone.
Categories (family members / labels)
await sky.categories.list(frameId);
await sky.categories.create(frameId, { label: 'Kids', color: '#00aaff' });
await sky.categories.update(frameId, categoryId, { color: '#ff0000' });
await sky.categories.delete(frameId, categoryId);Chores
await sky.chores.list(frameId, { after: '2026-07-13', before: '2026-07-20' });
// categoryId (the assignee) is required by the API
await sky.chores.create(frameId, { summary: 'Take out trash', start: '2026-07-16', rewardPoints: 5, categoryId });
await sky.chores.setStatus(frameId, choreId, 'complete'); // one-off chore ('complete', not 'completed'!)
await sky.chores.setStatus(frameId, choreId, 'complete', '2026-07-16'); // recurring instance
await sky.chores.update(frameId, choreId, { rewardPoints: 10 });
await sky.chores.delete(frameId, choreId);Lists (grocery, to-do, …)
await sky.lists.list(frameId);
const list = await sky.lists.get(frameId, listId); // includes list.items
// color is required by the API
await sky.lists.create(frameId, { label: 'Groceries', color: '#00aaff', kind: 'shopping' });
await sky.lists.addItem(frameId, listId, { label: 'Milk' });
await sky.lists.updateItem(frameId, listId, itemId, { completed: true });
await sky.lists.deleteItem(frameId, listId, itemId);
await sky.lists.delete(frameId, listId);Rewards & stars
await sky.rewards.list(frameId);
await sky.rewards.create(frameId, { name: 'Movie night', pointValue: 50, emojiIcon: '🍿' });
await sky.rewards.redeem(frameId, rewardId);
await sky.rewards.unredeem(frameId, rewardId);
await sky.rewards.points(frameId); // balance per category
await sky.rewards.adjustPoints(frameId, [categoryId], -10); // deduct starsTask box
await sky.taskBox.list(frameId);
const item = await sky.taskBox.addItem(frameId, 'Call the plumber');
await sky.taskBox.deleteItem(frameId, item.id);Escape hatch
The API surface is larger than what's wrapped here (meals, photos, routines, messages, …). Any endpoint can be called directly with the authenticated raw client:
const recipes = await sky.request('GET', `/frames/${frameId}/meals/recipes`);Error handling
Every error thrown by this library is a subclass of SkylightError, so a
single instanceof check catches anything that came from the client. Each
error message states what went wrong and what to do about it.
| Class | When | What to do |
| --- | --- | --- |
| SkylightInputError | Bad arguments (empty frameId, malformed credentials). Thrown before any network call. | Fix the call site — the message names the offending argument. |
| SkylightAuthError | Login or token refresh failed: wrong password, expired/consumed refresh token, pending 2FA. | Follow the message — e.g. re-login for a fresh refresh token, or complete device verification in a browser. |
| SkylightNetworkError | The request never reached Skylight (DNS, timeout, offline). Retried automatically first. | Check connectivity; the underlying error is on err.cause. |
| SkylightApiError | Skylight answered with a non-success status. | Inspect status, details and hint (see below). |
SkylightApiError parses the response body for you:
status— HTTP status codedetails— human-readable messages extracted from the response (handles JSON:API{ errors: [{ detail }] },{ error },{ message })hint— actionable advice for that status (e.g. 403 → "may require Skylight Plus", 422 → "check the field values in details")request—METHOD /paththat failedbody— the raw response body, for anything the parser missed
import {
SkylightApiError,
SkylightAuthError,
SkylightInputError,
SkylightNetworkError,
} from 'skylight-client';
try {
await sky.events.create(frameId, { summary: 'Test', startsAt: 'not-a-date' });
} catch (err) {
if (err instanceof SkylightInputError) {
// programming error — fix the arguments
} else if (err instanceof SkylightAuthError) {
// credentials problem — the message says which step failed and why
} else if (err instanceof SkylightApiError) {
console.error(err.message); // "Skylight API error 422 on POST /frames/42/calendar_events: Starts at is invalid — the request payload was rejected; check the values in `details` …"
console.error(err.status); // 422
console.error(err.details); // ["Starts at is invalid"]
console.error(err.hint); // "the request payload was rejected; check …"
} else if (err instanceof SkylightNetworkError) {
console.error('Skylight unreachable:', err.cause);
}
}Behavior built into the client (no code needed on your side):
- 401 → re-authenticates once (refresh grant or password login) and retries the request; only surfaces the error if renewal is impossible.
- 429 / 5xx / network failures → retried up to
maxRetriestimes (default 2) with exponential backoff, honoringRetry-After. - Concurrent 401s → collapsed into a single token refresh, so rotating refresh tokens aren't burned by parallel requests.
Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| SkylightAuthError: Login rejected on email/password | Wrong credentials, or Skylight is waiting on a device/2FA verification | Log into app.ourskylight.com in a browser once, approve the device, retry |
| SkylightAuthError: … invalid_grant … refresh token was rejected | Refresh token already used (they rotate!) or fingerprint mismatch | Persist the token from onTokenRefresh together with sky.fingerprint; if lost, log in with email/password again |
| SkylightApiError 404 on every call | Wrong frameId | Use the ids from sky.frames.list() — don't guess |
| SkylightApiError 403 | Feature not enabled for the frame | Some features (rewards, meals) require Skylight Plus |
| Works once, fails next process start | Rotated refresh token wasn't persisted | Implement onTokenRefresh (see Persisting tokens) |
How it works
- Base URL
https://app.ourskylight.com/api, JSON:API responses flattened into plain objects,skylight-api-version: 2026-03-01header on every request. - Email/password login replays the mobile app's OAuth2 authorization-code flow
headlessly (CSRF token → Rails session →
/oauth/authorize→/oauth/token) with client idskylight-mobile. - On a 401 the client refreshes (or re-logs-in) once and retries; concurrent requests share a single token refresh so rotating refresh tokens aren't burned.
Credits
Endpoint knowledge draws on:
- sebrandon1/go-skylight — Go client & CLI
- TheEagleByte/skylight-api — reverse-engineered OpenAPI spec
- kamilio/skylight-calendar-agent — CLI + MCP server
License
MIT
