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) callhost.fetch({ url: "..." })from your plugin - Implement a
uiCellinterface 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.