Docs/RecommendSDK

RecommendSDK

Universal Developer

RecommendSDK

Overview

RecommendSDK lets plugins register custom recommendation providers with the CoreBox engine. When the user opens CoreBox with an empty query, the engine calls getCandidates() on every registered provider, then ranks those candidates alongside the built-in frequency, time-of-day and trending dimensions.

Every candidate must be executable. A provider must implement onExecute(); the host uses it to actually run the action when the user triggers a candidate. Registering a provider without onExecute throws instead of leaving a card nothing can run. A successful execution counts as usage, so the next CoreBox open already reflects it.

Quick Start

EXAMPLE.JAVASCRIPT
// index.js (Prelude script)
module.exports = {
  async onInit() {
    // registerProvider returns a Promise and must be awaited
    const dispose = await recommend.registerProvider({
      id: 'my-plugin-weather',
      name: 'Weather Recommendations',

      canProvide(context) {
        // Morning only; the period lives on context.time.timeSlot
        return context.time.timeSlot === 'morning'
      },

      getCandidates(context) {
        return [
          {
            id: 'weather-today',
            title: "Today's Weather",
            subtitle: 'Check the forecast for today',
            icon: { type: 'emoji', value: '🌀️' },
            priority: 80,
            action: 'show-weather',
            data: { city: 'Shanghai' }
          }
        ]
      },

      // Required. false or a thrown error means the action failed and the host
      // counts nothing; true or undefined means the major action was accepted
      // and the host records one execution.
      async onExecute(candidate) {
        if (candidate.action !== 'show-weather') return false
        const ok = await openWeatherPanel(candidate.data.city)
        return ok
      }
    })

    // Release the provider and its retained callbacks when no longer needed
    // await dispose()
  }
}

API Reference

recommend.registerProvider(provider)

Register a recommendation provider.

Parameters:

FieldTypeRequiredDescription
provider.idstringYesUnique provider ID, unique within the plugin
provider.namestringYesDisplay name
provider.canProvide(context: ContextSignal) => boolean | Promise<boolean>YesWhether to provide recommendations; shares the 200 ms budget with candidate retrieval
provider.getCandidates(context: ContextSignal) => PluginRecommendCandidate[] | Promise<PluginRecommendCandidate[]>YesReturn recommendation candidates
provider.onExecute(candidate: PluginRecommendCandidate, args: IExecuteArgs) => boolean | void | Promise<boolean | void>YesExecute one candidate; see below

onExecute is required. registerProvider throws when it is missing, and the registration fails. The reason is that the candidate is clickable for the user, so a provider with no execution entry point would render a dead card.

Returns: Promise<() => void | Promise<void>> β€” must be awaited. The resolved disposer releases the provider's retained callbacks and removes it from the registry; the disposer itself also returns a Promise.

Note: provider IDs must be unique within a plugin. Disabling or unloading a plugin releases every provider it registered, so there is no need to unregister them one by one.

recommend.unregisterProvider(providerId)

Unregister a recommendation provider by ID.

Parameters:

  • providerId: string β€” The provider ID

Returns: Promise<boolean> β€” must be awaited. true when the provider was found and removed.

recommend.weights

The behaviour and time weighting the host itself uses, sharing one implementation with the grid ranking. These are pure functions over the arguments you pass: they make no host request and read no usage data.

MemberTypeDescription
behaviorScore(facts)(facts: UsageBehaviorFacts) => numberAutomatic behaviour score, 0..80
timeContribution(facts, now, nowMs?)(facts: UsageBehaviorFacts, now: TimePattern, nowMs?: number) => numberTime preference points, 0..20, 0 when the evidence is too thin
isFrequentEligible(facts)(facts: UsageBehaviorFacts) => booleanWhether the strict frequent threshold is met
pluginPriorityContribution(priority)(priority: number | undefined) => numberWhat a self-declared priority may add, capped at 5
modelVersionstringModel version, currently reco-model-2
constants.timeContributionMaxnumberCap on timeContribution, currently 20

The same implementation is also available as the standalone recommendWeights export; both expose the same functions with the same results.

The isolated child facade, plugin.recommend.weights, and Prelude's recommend.weights use the same model factory. Scoring runs synchronously in the plugin's own realm with frozen public functions and constants; it never fetches another source's behaviour data.

RecommendProvider

The provider object:

FieldTypeRequiredDescription
idstringYesUnique provider ID
namestringYesDisplay name
canProvide(context: ContextSignal) => boolean | Promise<boolean>YesWhether to provide recommendations; the host awaits the verdict within the same 200ms budget as candidate retrieval
getCandidates(context: ContextSignal) => PluginRecommendCandidate[] | Promise<...>YesReturn recommendation candidates
onExecute(candidate, args) => boolean | void | Promise<boolean | void>YesExecute one candidate

PluginRecommendCandidate

Each candidate item structure:

FieldTypeRequiredDescription
idstringYesUnique item ID, unique within the provider
titlestringYesDisplay title
subtitlestringNoSubtitle / description
icon{ type: string; value: string }NoIcon configuration, defaults to a lightbulb class; type accepts emoji, url, file, class, builtin
prioritynumberNoSelf-declared priority 0-100, worth at most 5 points; see "Behaviour and time model"
actionstringYesAction key, used in onExecute to tell your own actions apart
dataRecord<string, unknown>NoAdditional data, available in TuffItem.meta.pluginRecommend.data
providerIdstringNoAuto-filled by the host; a plugin does not set it

ContextSignal

The context object received by canProvide and getCandidates:

FieldTypeDescription
timeTimePatternCurrent time, see below
clipboard{ type, content, timestamp, contentType?, meta? }Clipboard content. content is a privacy digest, not the original text
selectionsame as clipboardThe latest text selection, same privacy tier as the clipboard
foregroundApp{ bundleId: string; name: string }Foreground application
systemState{ isOnline, networkType?, batteryLevel?, ... }System state

TimePattern fields:

FieldTypeDescription
hourOfDaynumberHour, 0-23
dayOfWeeknumberDay of week, 0-6, 0 is Sunday
isWorkingHoursbooleanWhether current time falls in working hours (weekdays 9-18)
timeSlot'morning' | 'afternoon' | 'evening' | 'night'Time slot

content on clipboard and selection is hashed. Judge the shape with contentType, meta.fileType, meta.isUrl and similar fields; never treat it as the original text.

Behaviour and Time Model

Automatic ranking lives in 0..100: behaviour is 0..80, time adds 0..20, and a recent-execution boost is a separate term in the same family capped at 10 points; the three are capped together at 100. The model version is reco-model-2.

ItemRule
behaviorScoreDerived from real, dated executions, 0..80. Recent, sustained and cross-day consistency terms each saturate, so a long history cannot permanently outrank a new habit
timeContribution0..20. Needs at least 10 valid executions over at least 3 distinct local days in the last 30 days; below that it is 0. Above it, the contribution still scales with evidence and decays with the age of the last execution
Recent-execution boostAt most 10 points, decaying per hour as exp(-0.1 * hours). It only accepts executions with a reliable event date; a single recent use is nowhere near 100, so sustained behaviour must accumulate before the automatic score climbs
pluginPriorityContributionA self-declared priority adds at most 5 points, can only order a plugin's own candidates, and can never earn frequent eligibility
isFrequentEligibleAt least 5 valid executions over at least 3 distinct local days in the last 30 days

UsageBehaviorFacts is the only input shape these functions take, and every field comes from real execution records: lifetime count, 30-day and 7-day counts, 30-day active days, the last execution time, an age-decayed execution score, and the 30-day slot distribution. Pass zeros when you have no evidence; never guess.

Exposures, cancels and last-search time are not behaviour facts, and these functions do not use them.

Execution and Counting

The host snapshots the result of getCandidates(). When the user triggers a candidate, the host calls onExecute(candidate, args) with the candidate from that snapshot β€” never a value the renderer supplied β€” so a provider only ever acts on its own action and data.

args is IExecuteArgs:

FieldTypeDescription
itemTuffItemThe executed item
searchResultTuffSearchResult | undefinedOrigin search result; absent on a direct trigger
actionIdstring | undefinedSecondary action key
eventIdstring | undefinedIdentifier of this user action

The onExecute return value decides whether the action counts:

Return valueResult
falseThe action failed; the host counts nothing
Thrown errorThe action failed; the host counts nothing
trueThe major action was accepted; the host records one execution
undefined (or resolves to undefined)The major action was accepted; the host records one execution

One user action is counted once. A retry or a duplicate notification reuses the same eventId, and the host dedupes on it, so the count does not grow.

Full Example

EXAMPLE.JAVASCRIPT
// index.js
let weatherData = null

module.exports = {
  async onInit() {
    const disposeProvider = await recommend.registerProvider({
      id: 'smart-weather',
      name: 'Smart Weather',

      canProvide(context) {
        return context.time.timeSlot === 'morning' && weatherData !== null
      },

      async getCandidates(context) {
        const items = [{
          id: 'weather-forecast',
          title: `Today ${weatherData.temp}Β°C ${weatherData.condition}`,
          subtitle: 'Click for details',
          icon: { type: 'emoji', value: '🌑️' },
          priority: 85,
          action: 'open-weather-detail',
          data: { city: weatherData.city }
        }]

        if (weatherData.rain) {
          items.push({
            id: 'weather-rain-alert',
            title: 'Rain expected today, bring an umbrella',
            subtitle: `Rain chance ${weatherData.rainChance}%`,
            icon: { type: 'emoji', value: '🌧️' },
            priority: 90,
            action: 'rain-detail',
            data: { rainChance: weatherData.rainChance }
          })
        }

        return items
      },

      async onExecute(candidate) {
        if (candidate.action === 'open-weather-detail') {
          return await openDetailPanel(candidate.data.city)
        }
        if (candidate.action === 'rain-detail') {
          return await openRainPanel(candidate.data.rainChance)
        }
        return false
      }
    })

    // A plugin can release registration as its own state changes
    globalThis.__disposeWeatherProvider = disposeProvider
  },

  onDestroy() {
    globalThis.__disposeWeatherProvider?.()
    globalThis.__disposeWeatherProvider = null
  },

  onFeatureTriggered(featureId, query, feature) {
    // Normal feature trigger logic
  }
}

Technical Notes

  • Isolated host calls: Prelude's recommend and plugin.recommend both use tenant-bound host capabilities. The host invokes the child's canProvide(), getCandidates() and onExecute() through retained callback handles; a plugin cannot select another plugin's owner or callbacks.
  • Timeout protection: qualification and candidate retrieval share one 200 ms budget per provider. Timeout or rejection skips only that provider; providers run concurrently.
  • Count limits: one provider may contribute at most 5 candidates per pass, and all providers together at most 15; excess items are trimmed.
  • Automatic cleanup: when a plugin is disabled or unloaded, all its registered providers are removed and their retained callbacks released.
  • Trusted execution source: the host executes only from its own candidate snapshot; renderer-supplied data never reaches onExecute.

Best Practices

  1. Keep canProvide lightweight β€” async is supported, but qualification and candidate retrieval share a 200 ms budget; avoid heavy computation or slow network waits.
  2. onExecute must really execute β€” returning true means the major action was accepted; never return true unconditionally just to look successful.
  3. Tell your own actions apart with action and data β€” onExecute only ever receives candidates this provider produced.
  4. priority is only an ordering nudge β€” capped at 5 points. Frequent eligibility and higher ranking come from real usage accumulating behaviour evidence.
  5. Limit candidate count β€” return 1-5 candidates per provider; excess items are trimmed by ranking.
  6. Release resources β€” use the disposer resolved by await registerProvider(...), or unregister the provider in onDestroy.