Skip to content

SDK Reference — @ontrove/sdk

The @ontrove/sdk package is the source-adapter authoring library. It provides the TroveSource type, the typed ctx object passed to sync, and the document shape your source adapter returns. It is published to npm as @ontrove/sdk; install it as a dev dependency for types and local tooling.

Terminal window
npm install @ontrove/sdk

The type your default export satisfies. A source adapter is an object with a sync method (and, optionally, a query method for live sources).

index.ts
import type { TroveSource } from "@ontrove/sdk";
export default {
async sync(ctx) {
// fetch data, return documents
return [];
},
} satisfies TroveSource;
MemberTypeRequiredDescription
sync(ctx: SourceContext) => Promise<SourceSyncResult | SourceDocument[]>YesThe batch entry point. Fetches from the upstream and returns documents to index — either a SourceSyncResult ({ documents, cursor? }) or a bare SourceDocument[].
query(ctx: SourceContext) => Promise<SourceDocument[]>No (proposed)A live, real-time variant called on demand instead of on a schedule. Set "live": true in the manifest to enable it.

Using satisfies TroveSource (rather than a type annotation) keeps the inferred types on your object while checking it against the contract — you get autocomplete on ctx and the document fields.


The core of every source adapter. Called by the Mac app on the source’s schedule. It receives a ctx object and returns an array of documents.

async sync(ctx) {
const res = await ctx.fetch(ctx.config.feedUrl);
const items = parse(await res.text());
return items.map((item) => ({
id: item.guid,
title: item.title,
text: item.body,
url: item.link,
author: item.author,
date: item.published,
}));
}

sync should be idempotent: returning the same document id twice is safe — Trove deduplicates by id within the feed. Re-running a sync never creates duplicates, so you can safely return overlapping batches.


The single argument to sync. A capability object: everything your source adapter reaches the outside world through is on ctx.

ctx.config: Record<string, unknown>

The user’s preference values, keyed by the field names you declared in manifest.json config. If your manifest declares { "username": { ... } }, then ctx.config.username holds what the user entered.

const username = ctx.config.username as string;
const feeds = ctx.config.feeds as string[];

Config holds preferences only — never credentials. Feed URLs, usernames, section lists, and filters live here. Auth material (cookies, API keys, OAuth tokens) is held by the Mac app in the macOS Keychain and surfaced separately (see ctx.credentials), never stored in the cloud.

ctx.cursor: Watermark // tagged union: { type: 'date' | 'idSet' | 'none' }
type Watermark =
| { readonly type: "date"; readonly value: string }
| { readonly type: "idSet"; readonly values: readonly string[]; readonly max?: string }
| { readonly type: "none" };

The sync position from the previous run — a Watermark tagged union, not a bare string. It is { type: "none" } on the first sync. Read its type to fetch only new content; the Watermark you return from sync (in { documents, cursor }) becomes the next run’s ctx.cursor. See Cursors and Feeds for the watermark types and the compare-and-swap protocol.

// A date-ordered upstream resumes from the last date watermark.
const since = ctx.cursor.type === "date" ? ctx.cursor.value : "1970-01-01T00:00:00Z";
const res = await ctx.fetch(`${api}?updated_after=${since}`);
// ...later, return the new high-water mark:
return { documents, cursor: { type: "date", value: newestDate } };
ctx.fetch(url: string | URL, init?: RequestInit): Promise<Response>

Behaves like the standard fetch API. Prefer ctx.fetch over global fetch — the SDK routes it through the Mac app’s networking, applying per-source timeouts, retry, and rate-limit handling, and surfacing failures in the source’s error log.

const res = await ctx.fetch("https://hn.algolia.com/api/v1/search?tags=front_page");
const data = await res.json();
ctx.credentials: Record<string, string>

Auth material for sources that need it (an API key, a browser cookie), resolved by the Mac app from the macOS Keychain at sync time. The keys correspond to auth fields declared in the manifest. Never copy a credential into a document’s text or metadata — the source adapter is responsible for stripping auth material before constructing payloads.

ctx.browser: Browser // Playwright Browser

A Playwright Browser instance, available only when the manifest sets "needs_browser": true. Use it for upstreams with no API that require a logged-in session (e.g. social bookmarks). Browser-based source adapters run only on the Mac app, never in the cloud.

ctx.log(...args: unknown[]): void

Structured log entry, surfaced in the Mac app’s source logs and (when present) the createSyncRun record. Useful for reporting counts and progress.

ctx.log(`fetched ${items.length} items since ${ctx.cursor.type === "date" ? ctx.cursor.value : "start"}`);

Your sync returns a SourceSyncResult ({ documents, cursor? }) or, for convenience, a bare array of SourceDocument objects. The SDK fields map onto IngestDocumentInput when the Mac app pushes them via ingestDocuments.

interface SourceDocument {
id: string; // → externalId (wire)
title?: string;
text?: string; // required unless audioUrl is set
audioUrl?: string; // → triggers transcription
url?: string;
author?: string;
date?: string; // ISO 8601 → content date
tags?: string[];
metadata?: Record<string, unknown>;
contentType?: "text" | "transcript" | "highlight" | "bookmark";
}
FieldRequiredMaps toDescription
idYesexternalIdStable identifier from the upstream system. The dedup key within the feed — re-returning the same id is skipped. Use the native id (HN objectID, RSS guid, Notion page id).
titleNotitleDocument title.
textNo*textFull text content. Strip HTML/markup to plain text for best search quality.
audioUrlNo*audioUrlURL to an audio file. Trove downloads and transcribes it (Whisper); the transcript becomes the document text.
urlNourlCanonical link back to the original.
authorNoauthorContent author. For podcasts, set this to the show name.
dateNodateOriginal creation date, ISO 8601. Used for recency ranking and “published” display.
tagsNotagsArray of string tags.
metadataNometadataArbitrary JSON for source-specific data. Never put credentials or auth headers here.
contentTypeNocontentTypeOne of text, transcript, highlight, bookmark. Defaults to text (transcribed audio becomes transcript automatically).

* At least one of text or audioUrl must be provided.

If your source adapter fetches audio (podcasts, voice memos, meeting recordings), return audioUrl instead of text. Trove detects the audio document, downloads it, runs transcription, stores the transcript as the document text, and indexes it with contentType: transcript.

return episodes.map((ep) => ({
id: ep.guid,
title: `${ep.showName}: ${ep.title}`,
// No text — audioUrl tells Trove to transcribe.
audioUrl: ep.enclosureUrl,
url: ep.link,
author: ep.showName, // show name in the author field, per Trove convention
date: ep.pubDate,
}));

You can provide both text (e.g. show notes) and audioUrl — the show notes are indexed immediately, and the transcript is added when transcription completes. Transcription is asynchronous and metered (Whisper, billed per minute).


sync runs inside the Mac app’s sync engine, which owns scheduling and retry. The contract:

  • Throw to fail the run. If sync throws, the Mac app records an errored SyncRun (status: error, with the message) and retries on the next scheduled tick. Throw a plain Error with a descriptive message.
  • Partial success is fine. Return whatever documents you successfully fetched; you don’t need all-or-nothing. The cursor only advances on documents that were actually pushed.
  • Idempotent retries. Because dedup is keyed on (feed, id), a retried run that re-fetches overlapping content pushes no duplicates. Don’t try to track “already sent” state yourself — let dedup handle it.
  • Cursor never regresses. The cloud advances the feed cursor only if the new value is monotonically ahead (compare-and-swap on cursorBefore). A crashed-then-retried sync resumes from the last committed cursor, not an earlier one. See Cursors and Feeds.
async sync(ctx) {
const res = await ctx.fetch(ctx.config.feedUrl as string);
if (!res.ok) {
// Fails this run; the Mac app retries next tick. Already-pushed docs persist.
throw new Error(`feed returned ${res.status} ${res.statusText}`);
}
return parse(await res.text());
}

Complete example — multi-feed RSS source

Section titled “Complete example — multi-feed RSS source”
index.ts
import type { TroveSource } from "@ontrove/sdk";
export default {
async sync(ctx) {
const feeds = ctx.config.feeds as string[];
// ctx.cursor is a Watermark — a date-ordered upstream uses { type: "date" }.
const since = ctx.cursor.type === "date" ? new Date(ctx.cursor.value) : new Date(0);
let newest = since;
const docs = [];
for (const feedUrl of feeds) {
const res = await ctx.fetch(feedUrl);
if (!res.ok) {
ctx.log(`skipping ${feedUrl}: ${res.status}`);
continue; // partial success — keep going
}
for (const item of parseRss(await res.text())) {
const pub = new Date(item.pubDate);
if (pub <= since) continue; // incremental
if (pub > newest) newest = pub;
docs.push({
id: item.guid,
title: item.title,
text: stripHtml(item.contentEncoded ?? item.description),
url: item.link,
author: item.creator ?? feedUrl,
date: item.pubDate,
});
}
}
ctx.log(`returning ${docs.length} new items across ${feeds.length} feeds`);
return { documents: docs, cursor: { type: "date", value: newest.toISOString() } };
},
} satisfies TroveSource;