TypeScript SDK
@crossfeed/sdk is a typed client for the REST API: one network’s roster, launches, and inbox, with one of its API keys. Its methods mirror the API’s routes, typed from the OpenAPI document, and it needs nothing but fetch, so it runs in Node, Bun, Deno, and Workers.
Install from the repo
It isn’t on npm yet. From your project’s directory, clone the repo beside it, build the package, and add it by its path:
git clone https://github.com/m1kuorg/qloo.git ../qloo
(cd ../qloo/packages/sdk && bun install)
bun add ../qloo/packages/sdkbun install builds the package’s dist/. With npm, add it with npm install ../qloo/packages/sdk. Bun and Deno can also import packages/sdk/src/index.ts as it is.
Create a client
The client takes one of the network’s API keys and its ID, both from its settings:
import { Crossfeed } from '@crossfeed/sdk'
const crossfeed = new Crossfeed({
apiKey: process.env.CROSSFEED_API_KEY!, // cf_live_…
networkId: process.env.CROSSFEED_NETWORK_ID!,
baseUrl: 'https://crossfeed.m1kuorg.workers.dev' // the default is https://crossfeed.m1kuorg.workers.dev
})fetch replaces the runtime’s own, such as a service binding’s in a Worker. Its namespaces follow the API: network, roster, launches, runs, insertionOrders, evidence, inbox, pitchRuns, and replies. None of them approves anything.
Plan a launch
Import the roster, settle what Qloo couldn’t match on its own, then run the launch. A run asks the person planning it its questions, such as the slot budget: relay each one and answer with their words.
await crossfeed.roster.import('name,downloads\nDeep Cuts,12000\nField Notes,8000\n')
await crossfeed.waitForRoster()
await crossfeed.roster.updateShow('Field Notes', { entityId: 'e-0102' }) // one of its candidates
const launch = await crossfeed.launches.create({ name: 'Night Shift Cinema' })
const { id: runId } = await crossfeed.runs.start(launch.id)
let run = await crossfeed.waitForRun({ launchId: launch.id, runId })
while (run.status === 'waiting') {
const answer = await askThePerson(run.question!) // their words, such as { slots: 20 }
await crossfeed.runs.answer(launch.id, runId, { questionId: run.question!.id, ...answer })
run = await crossfeed.waitForRun({ launchId: launch.id, runId })
}
for (const row of run.chart!.rows) console.log(row.rank, row.show, row.band)
console.log(run.insertionOrder) // the draft, priced at the network's CPMaskThePerson is yours: a prompt, a form, a message. Each question’s kind says which answer fields it takes. A member approves the order on the launch’s page in the app; until then insertionOrders.exportCsv rejects with conflict, and after, it resolves with the CSV the app exports.
Triage pitches
Load the day’s pitches as an .mbox export, an .eml, text with a line of --- between pitches, or one by one, then rank them against one of the network’s shows:
await crossfeed.inbox.load({ pitches: [{ subject: 'A guest for Deep Cuts', body: '…' }] })
await crossfeed.waitForInbox()
const { id } = await crossfeed.pitchRuns.start({ show: 'Deep Cuts' })
const run = await crossfeed.waitForRun({ pitchRunId: id })
for (const reply of run.replies ?? []) console.log(reply.pitch, reply.verdict, reply.draft)A member approves each reply in the app before anyone sends it; Crossfeed sends no email.
Wait for long work
Each wait helper reads its resource until the work settles:
waitForRoster()resolves once no show or comp search is pending.waitForOverlap()resolves once the roster’s overlap, whichroster.measureOverlap()starts, is no longermeasuring.waitForListeners()resolves once the roster’s listener profile, whichroster.profileListeners()starts, is no longerprofiling.waitForInbox()resolves once no pitch is still being read.waitForRun({ launchId, runId })andwaitForRun({ pitchRunId })resolve once the run iscompleted, orwaitingon its question, and reject withrun_failedorrun_stoppedwhen it failed or stopped.
Each takes { interval, timeout, signal }: it reads every interval milliseconds (1,000), rejects with timeout after timeout milliseconds (5 minutes), and stops at once when signal aborts.
Handle errors
Every refusal rejects with a CrossfeedError: the API’s code, its message for a person, and the HTTP status.
import { CrossfeedError } from '@crossfeed/sdk'
try {
await crossfeed.roster.import(csv)
} catch (error) {
if (error instanceof CrossfeedError && error.code === 'conflict') await crossfeed.roster.import(csv, { replace: true })
else throw error
}The codes are the API’s, in the errors table. The wait helpers add run_failed, run_stopped, and timeout, with a null status, and unexpected_response is an answer that isn’t one of Crossfeed’s, as from a wrong baseUrl.