Skip to main content

Writing Your First Plugin

End-to-end walkthrough. We'll clone the seeded rolling-avg-rpm plugin, modify it to compute a rolling average over a different channel, bundle, and upload.

1. Start from the example

cp -r packages/plugin-sdk/examples/rolling-avg-rpm \
packages/plugin-sdk/examples/rolling-avg-brake-temp
cd packages/plugin-sdk/examples/rolling-avg-brake-temp

2. Update the manifest

plugin.json:

{
"id": "your-team:rolling-avg-brake-temp",
"version": "1.0.0",
"interfaces": ["math", "kpiProcessor"],
"capabilities": [],
"publisher": "Your Team",
"description": "Rolling-average brake temperature for the BrakeTemp channel."
}

3. Update the code

src/index.ts — change the math function name and the channel the KPI processes:

export const math = defineMath({
listFunctions() {
return [{
name: "rollingAvgBrakeTemp",
arity: 2,
pure: true,
description: "Rolling avg brake temp. Args: samples (JSON), windowSec.",
}];
},
call(name, args) {
if (name !== "rollingAvgBrakeTemp") {
return { kind: "error", message: `unknown: ${name}` };
}
// ... same body as the example, but reading BrakeTemp samples
},
});

export const kpiProcessor = defineKpiProcessor({
kpis() {
return [{
name: "BrakeTempRollingAvg5s",
description: "Lap-mean of the 5-second rolling average of BrakeTemp.",
}];
},
process(lapId, lap) {
const samples = lap.samples
.filter((s) => s.channel === "BrakeTemp")
.map((s) => ({ tSec: s.tSec, value: s.value }));
if (samples.length === 0) {
return [{ lapId, kpiName: "BrakeTempRollingAvg5s",
value: { kind: "error", message: "no BrakeTemp samples" }}];
}
const rolling = rollingAverage(samples, 5.0);
const lapMean = rolling.reduce((a, b) => a + b, 0) / rolling.length;
return [{ lapId, kpiName: "BrakeTempRollingAvg5s",
value: { kind: "number", value: lapMean }}];
},
});

export default { math, kpiProcessor };

4. Bundle

pnpm tsx ../../tools/bundle-plugin.ts
ls dist/plugin.js # → bundle ready

5. Upload

The easy way — copy the bundle-and-upload script and tweak the import path:

cp ../../scripts/bundle-and-upload.ts ./upload.ts
# Edit upload.ts to point at the new example directory
pnpm tsx ./upload.ts

Or do it by hand:

TOKEN=$(curl -s -X POST http://localhost:6161/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"demo@race.local","password":"demo","accountSlug":"demo-racing"}' \
| jq -r .token)

MANIFEST=$(cat plugin.json)
BUNDLE=$(base64 -i dist/plugin.js)

curl -X POST http://localhost:6161/plugins/upload \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg b "$BUNDLE" --argjson m "$MANIFEST" \
'{manifest: $m, bundleBase64: $b}')"

6. Verify

curl -s -H "Authorization: Bearer $TOKEN" \
http://localhost:6161/plugins \
| jq '.items[] | select(.manifest.id == "your-team:rolling-avg-brake-temp")'

You should see your plugin listed.

7. Run it

Open the app → Analysis → Plugin Runner. Pick your plugin in the left pane. The right pane shows the manifest. Click Math or KPI Processor, fill in some sample inputs, hit Invoke.

The result panel shows the typed PluginValue result — green border for success, red for error.

8. Iterate

Change the source, re-bundle, re-upload. Same id+version overwrites the existing row (the upload endpoint is idempotent).

Going further

  • Add a test: pnpm vitest tests/ — the SDK's parity test will keep your bundle honest across runtimes
  • Request a capability: add "fetch" to the manifest's capabilities array and (once capability injection lands) call host.fetch({ url: "..." }) from your plugin
  • Implement a uiCell interface to render plugin-defined UI cells inside CID layouts (this interface is typed but the host wiring is roadmap)

See Capabilities and Security and Running on Client vs Server for the host-side specifics.