Ten minutes from nothing to a plug-in that passes its checks: download the tool, start a folder, teach it your file, check it, preview your days, pack it.
You will build Harbour Tides: the day's high and low water on your days. You need Node 24 and a terminal. Every command here runs on every build of this site, and the output below is what it printed.
curl -O https://kanboombo.com/developers/plugins/kanboombo-plugin.mjs
curl -O https://kanboombo.com/developers/plugins/kanboombo-plugin.mjs.sha256
shasum -a 256 -c kanboombo-plugin.mjs.sha256
shasum should say kanboombo-plugin.mjs: OK.
2. Start a plug-in
node kanboombo-plugin.mjs init harbour-tides --timezone Pacific/Auckland
Created harbour-tides/, a value plug-in in Pacific/Auckland:
plugin.json the manifest: what it downloads, what it writes, when it runs
fetch.mjs the fetcher: reads data.csv, prints records
fixtures/sources/data.csv a stand-in for your source
fixtures/context.json the moment check runs at: 2026-10-01 in Pacific/Auckland
fixtures/records.jsonl what fetch.mjs prints for it: 5 records
README.md what to change next
AGENTS.md the road to the store, for your agent
CLAUDE.md points Claude Code at AGENTS.md
Next: node kanboombo-plugin.mjs check harbour-tides
The time zone decides your plug-in's today: Auckland's 1 October starts while it is still 30 September in UTC, so context.json says both. AGENTS.md is for your agent.
3. Check it
node kanboombo-plugin.mjs check harbour-tides
✔ harbour-tides: 5 records, contract 1
digest 24baf84eec683978e967b7ebd8eaaf0a274308267ce5781db042bad5cd58a406
It ran fetch.mjs on the stand-in with no network, checked every record, and matched fixtures/records.jsonl. The digest is what an approval covers: one changed character of code changes it.
4. Teach it your file
Your source is a tide table, a row a day. Save a real copy as the fixture, the file every check reads instead of the network:
date,high,low
2026-09-27,2.51,0.34
2026-09-28,2.47,0.35
2026-09-29,2.43,0.42
2026-09-30,2.29,0.50
2026-10-01,2.20,0.63
2026-10-02,2.06,0.74
The last row predicts tomorrow. No record may be dated after today, so the fetcher will leave it out. Now say what you download and write:
{
"$schema": "https://kanboombo.com/developers/plugins/plugin.schema.json",
"contract": 1,
"id": "harbour-tides",
"name": { "en": "Harbour Tides", "es": "Mareas del puerto" },
"version": "0.1.0",
"timezone": "Pacific/Auckland",
"audience": "private",
"schedule": "15 6 * * *",
"backfill": "1y",
"sources": [{ "name": "tides", "url": "https://example.com/tides/{year}.csv", "maxBytes": 65536 }],
"series": [
{
"key": "tide",
"kind": "value",
"cadence": "daily",
"dates": "observation",
"name": { "en": "High and low tide", "es": "Pleamar y bajamar" },
"unit": "m",
"fields": ["high", "low"],
"fieldNames": {
"high": { "en": "High", "es": "Pleamar" },
"low": { "en": "Low", "es": "Bajamar" }
},
"change": "high",
"snapshot": { "back": "30d", "ahead": "0d" }
}
],
"licence": { "holder": "Your harbour", "attribution": "Harbour", "terms": null, "retain": "unknown" }
}
And a fetcher that reads it:
#!/usr/bin/env node
// Harbour Tides: reads tides.csv (date,high,low) and prints one value record a day.
import { readFileSync } from "node:fs";
function fail(reason) {
process.stderr.write(`harbour-tides: ${reason}\n`);
process.exit(2);
}
const context = JSON.parse(readFileSync(0, "utf8"));
const { today } = context; // the date in Pacific/Auckland, worked out for you
const file = context.sources.tides; // read the path you are given; never build it
if (!file) fail("the run context names no source called tides");
const [header, ...rows] = readFileSync(file, "utf8").trim().split("\n").map((line) => line.split(","));
const at = Object.fromEntries(header.map((name, index) => [name.trim(), index]));
for (const column of ["date", "high", "low"]) if (at[column] === undefined) fail(`tides.csv has no column named ${column}`);
for (const cells of rows) {
const date = cells[at.date];
const high = Number(cells[at.high]);
const low = Number(cells[at.low]);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(high) || !Number.isFinite(low)) fail(`tides.csv has a row it cannot read: ${cells.join(",")}`);
if (date > today) continue; // a prediction, not an observation
process.stdout.write(`${JSON.stringify({ kind: "value", series: "tide", date, fields: { high, low } })}\n`);
}
Remove the old stand-in, and check again:
rm harbour-tides/fixtures/sources/data.csv
node kanboombo-plugin.mjs check harbour-tides
Kanboombo plug-in checks: harbour-tides
✖ PLG-FIXTURE-MISMATCH fixtures/records.jsonl: fixtures/records.jsonl line 1: expected {"kind":"value","series":"main","date":"2026-09-27","fields":{"value":10.2}}, got {"kind":"value","series":"tide","date":"2026-09-27","fields":{"high":2.51,"low":0.34}}
Fix: If the change is intended, run check with --update and read the difference before you keep it.
https://kanboombo.com/developers/plugins/publish/submission-errors/#plg-fixture-mismatch
The check shows the first line that changed. You meant it, so keep it:
node kanboombo-plugin.mjs check harbour-tides --update
✔ harbour-tides: 5 records, contract 1
digest 100cf8a2f84e184343f9afe5bf5d681b9301178c1627e73eba146c046e8e27a7
Five records, not six: the prediction stayed out.
5. See your days
node kanboombo-plugin.mjs preview harbour-tides --days 2026-09-30..2026-10-02
Harbour Tides: Wed 30 Sep to Fri 2 Oct 2026 (language en)
5 records from fixtures/sources; today is 2026-10-01 in Pacific/Auckland.
Wed 30 Sep
High and low tide · High 2.29, Low 0.50 · −5.76% · Harbour
Thu 1 Oct · today
High and low tide · High 2.20, Low 0.63 · −3.93% · Harbour
Fri 2 Oct
(nothing on this day)
These are the app's rows, worked out by its own code: the figures under their names, the change of high (never coloured good or bad), your attribution, and nothing after today. Add --language es for your Spanish names, or --html days.html for a page you can open.
6. Pack it
node kanboombo-plugin.mjs pack harbour-tides
✔ Packed harbour-tides 0.1.0: 8 files, 5 records
harbour-tides-0.1.0.zip …
sha256 …
digest 100cf8a2f84e184343f9afe5bf5d681b9301178c1627e73eba146c046e8e27a7
⚠ README.md is still init's template; write your own before you upload.
pack checks first and refuses a plug-in that fails. The same files make the same ZIP on the same Node (size and hash, which follow its compression, are elided). Before you upload it at kanboombo://publish, add a case that must fail: the portal asks for at least one. Publish.
Next
Harbour Almanac uses all of contract 1; Dates and time zones holds the mistakes everyone makes once.