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.
npm install @ontrove/sdkTroveSource
Section titled “TroveSource”The type your default export satisfies. A source adapter is an object with a sync method (and, optionally, a query method for live sources).
import type { TroveSource } from "@ontrove/sdk";
export default { async sync(ctx) { // fetch data, return documents return []; },} satisfies TroveSource;| Member | Type | Required | Description |
|---|---|---|---|
sync | (ctx: SourceContext) => Promise<SourceSyncResult | SourceDocument[]> | Yes | The 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.
sync(ctx)
Section titled “sync(ctx)”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 ctx object
Section titled “The ctx object”The single argument to sync. A capability object: everything your source adapter reaches the outside world through is on ctx.
ctx.config
Section titled “ctx.config”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
Section titled “ctx.cursor”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, init?)
Section titled “ctx.fetch(url, init?)”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 (proposed)
Section titled “ctx.credentials (proposed)”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 (proposed)
Section titled “ctx.browser (proposed)”ctx.browser: Browser // Playwright BrowserA 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)
Section titled “ctx.log(...args)”ctx.log(...args: unknown[]): voidStructured 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"}`);The document shape
Section titled “The document shape”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";}| Field | Required | Maps to | Description |
|---|---|---|---|
id | Yes | externalId | Stable 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). |
title | No | title | Document title. |
text | No* | text | Full text content. Strip HTML/markup to plain text for best search quality. |
audioUrl | No* | audioUrl | URL to an audio file. Trove downloads and transcribes it (Whisper); the transcript becomes the document text. |
url | No | url | Canonical link back to the original. |
author | No | author | Content author. For podcasts, set this to the show name. |
date | No | date | Original creation date, ISO 8601. Used for recency ranking and “published” display. |
tags | No | tags | Array of string tags. |
metadata | No | metadata | Arbitrary JSON for source-specific data. Never put credentials or auth headers here. |
contentType | No | contentType | One of text, transcript, highlight, bookmark. Defaults to text (transcribed audio becomes transcript automatically). |
* At least one of text or audioUrl must be provided.
Audio documents → transcription
Section titled “Audio documents → transcription”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).
Error and retry behavior
Section titled “Error and retry behavior”sync runs inside the Mac app’s sync engine, which owns scheduling and retry. The contract:
- Throw to fail the run. If
syncthrows, the Mac app records an erroredSyncRun(status: error, with the message) and retries on the next scheduled tick. Throw a plainErrorwith 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”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;See also
Section titled “See also”- manifest.json Reference — the manifest fields that drive
ctx.config,schedule, andneeds_browser - Cursors and Feeds — watermark types and the cursor compare-and-swap protocol
- Sync API — the
ingestDocumentswire format your documents map onto IngestDocumentInput— the GraphQL document input type- Full API reference (
@ontrove/sdk) — every export, generated from the package’s types