Skip to content

Scoring Plugin — Getting Started

This guide walks through building a new scoring plugin against the ScoringPluginV1 interface (RFC-0002). For the governance and security boundary, see RFC-0002: Scoring Plugin. For mentor pairings, see Adapter Mentorship Paths — the scoring area mentor is @maintainer-scoring.

Prerequisites

  • Node.js 22+
  • The monorepo checked out and installable (pnpm install).
  • Familiarity with TypeScript and async/await.
  • A read of RFC-0002 — particularly the Sandboxing and security boundary section.

1. Look at the reference plugin

The canonical example lives at examples/scoring/word-count-plugin/. Read it end-to-end before writing your own — it is short on purpose. It implements the full V1 contract: id, metadata, score, plus a unit test demonstrating the conformance corners.

2. Implement ScoringPluginV1

The interface has three methods. Implementations must resolve all promises (never throw) — surface failures via ok: false results instead.

ts
import type { ScoringPluginV1, SubmissionView } from '@lenserfight/plugins-scoring'

export function createMyScoringPlugin(): ScoringPluginV1 {
  return {
    id: () => 'my-plugin',
    metadata: () => ({
      displayName: 'My Plugin',
      // Declare every signal key you will ever emit. The worker drops
      // out-of-set keys and writes a `signal_not_declared` audit row.
      signals: ['my_plugin.score'],
    }),
    score: async (submission: SubmissionView) => {
      try {
        const value = computeMySignal(submission)
        return { ok: true, signals: { 'my_plugin.score': value } }
      } catch (err) {
        return { ok: false, reason: err instanceof Error ? err.message : 'unknown' }
      }
    },
  }
}

The same shape is used by examples/scoring/word-count-plugin/src/plugin.ts.

3. Register the plugin

Registration adds the plugin to the in-process registry so the platform-api worker can resolve it by id. Registration happens at platform-api boot, not at request time:

ts
import { registerScoringPlugin } from '@lenserfight/plugins-scoring'
import { createMyScoringPlugin } from './my-plugin'

registerScoringPlugin('my-plugin', () => createMyScoringPlugin())

Plugins are not loaded dynamically. Adding a plugin requires merging it into examples/scoring/ (or wiring it into the platform-api bundle) and rebuilding. This is intentional — see RFC-0002 for the security rationale.

4. Test conformance

Plugin tests should cover the documented contract corners:

ts
describe('myScoringPlugin', () => {
  const plugin = createMyScoringPlugin()

  it('emits only declared signal keys', async () => {
    const r = await plugin.score(stubSubmission())
    if (!r.ok) throw new Error('expected ok')
    for (const key of Object.keys(r.signals)) {
      expect(plugin.metadata().signals).toContain(key)
    }
  })

  it('never throws — returns ok=false on internal error', async () => {
    const r = await plugin.score(badSubmission())
    expect(typeof r.ok).toBe('boolean')
  })

  it('completes within the per-plugin timeout budget', async () => {
    const start = Date.now()
    await plugin.score(stubSubmission())
    expect(Date.now() - start).toBeLessThan(5000)
  })
})

Run the project's tests with pnpm nx test plugins-scoring and any nested example projects.

5. Verify the persistence path

Plugins do not write to the database directly. The worker calls public.fn_record_scoring_plugin_signal on their behalf. To verify end-to-end:

  1. Run the platform-api worker locally with your plugin registered.
  2. Create a battle and post a submission via lf battle local run or the cloud worker (in dev).
  3. Inspect battles.scoring_plugin_signals for rows tagged with your plugin_id.
  4. Confirm every row's signal_key is one of the keys declared in metadata().signals.

A row missing for a successful submission means the worker rejected your output for being out-of-set — check the audit.action_logs table for the matching signal_not_declared row.

What's stable, what's not

ScoringPluginV1 is governed by RFC-0002:

  • Stable in V1: the three method signatures, the displayName and signals fields on metadata(), the { ok: true; signals } | { ok: false; reason } result shape.
  • Subject to additive change in V1 minor releases: optional fields on SubmissionView, additional well-known signal name conventions.
  • Breaking changes: bump to ScoringPluginV2 with a deprecation cycle; V1 continues to work during overlap.

Pin to the versioned symbol (ScoringPluginV1) so a future V2 cannot silently change the shape under you.

Next steps