npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

skylight-client

v0.1.0

Published

Unofficial TypeScript/JavaScript client for the Skylight Calendar API (app.ourskylight.com)

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

Quick 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/... calendars

Calendar 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 stars

Task 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 code
  • details — 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 /path that failed
  • body — 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 maxRetries times (default 2) with exponential backoff, honoring Retry-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-01 header 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 id skylight-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:

License

MIT