Developers · Examples

Harbour Almanac

The kitchen-sink example: one plug-in that uses every part of contract 1 on an invented harbour, file by file, so you can copy the part you need.

Harbour Almanac puts an invented harbour's tides, fish landings, council meetings and notices to mariners on your days. The data is invented; the code is how a real plug-in is written, and every part of contract 1 is in it. Copy any of it: it is MIT-0.

Harbour Almanac on Thu 1 Oct, as the app draws it
THURSDAY
Oct 1
2026
◌ Harbour council: berth fees
Scheduled · Harbour Almanac
High and low tide
2.20
High
0.63
Low
−3.93%
Harbour Almanac
Fish landed · 2026-08
399.00
Landed
−14.38%
Last rate · Thu 10 Sep · Harbour Almanac
Tide table for October 2026 published
Leading light on the east bank shows a reduced range
sh
curl -O https://kanboombo.com/developers/plugins/examples/harbour-almanac.zip unzip harbour-almanac.zip
Series
Kind and dates
Source
What it shows you
Series
tide
Kind and dates
value, daily, observation
Source
tides/{year}.csv
What it shows you
fields, fieldNames in two languages, change, a unit, a 30-day snapshot
Series
catch
Kind and dates
value, monthly, publication
Source
catch.csv
What it shows you
period, and revises for a corrected figure
Series
council
Kind and dates
event
Source
council.json?as-of={today}
What it shows you
scheduled, happened, cancelled and moved meetings; a value on an event; a window 90 days back and 180 ahead
Series
notices
Kind and dates
item
Source
notices/{year}-{month}.json
What it shows you
links to a host links.hosts declares; no snapshot

plugin.json

15 6,18 * * * is 06:15 and 18:15 in Auckland. Each source fills in the day its own way and asks for no more bytes than it needs; links.hosts names the notices' link host, and limits asks for 30 seconds and 64 MB.

plugins/harbour-almanac/plugin.json
{ "$schema": "https://kanboombo.com/developers/plugins/plugin.schema.json", "contract": 1, "id": "harbour-almanac", "name": { "en": "Harbour Almanac", "es": "Almanaque del puerto" }, "version": "1.0.0", "timezone": "Pacific/Auckland", "audience": "public", "schedule": "15 6,18 * * *", "backfill": "1y", "sources": [ { "name": "tides", "url": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/sources/tides/{year}.csv", "maxBytes": 65536 }, { "name": "catch", "url": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/sources/catch.csv", "maxBytes": 65536 }, { "name": "council", "url": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/sources/council.json?as-of={today}", "maxBytes": 262144 }, { "name": "notices", "url": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/sources/notices/{year}-{month}.json", "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" } }, { "key": "catch", "kind": "value", "cadence": "monthly", "dates": "publication", "name": { "en": "Fish landed", "es": "Pesca desembarcada" }, "unit": "t", "fields": ["landed"], "fieldNames": { "landed": { "en": "Landed", "es": "Desembarcado" } } }, { "key": "council", "kind": "event", "cadence": "irregular", "dates": "observation", "name": { "en": "Harbour council", "es": "Consejo del puerto" }, "unit": "NZD", "snapshot": { "back": "90d", "ahead": "180d" } }, { "key": "notices", "kind": "item", "cadence": "irregular", "dates": "observation", "name": { "en": "Notices to mariners", "es": "Avisos a los navegantes" } } ], "links": { "hosts": ["notices.harbour-almanac.example"] }, "limits": { "seconds": 30, "megabytes": 64 }, "licence": { "holder": "Kanboombo (invented example data)", "attribution": "Harbour Almanac", "terms": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/", "retain": "yes" } }

fetch.mjs

since() decides how far back to print, source() reads the path it is given, and fail() exits 2 on anything unknown. It skips predictions, maps held to happened, and holds back a meeting cancelled ahead of its day.

plugins/harbour-almanac/fetch.mjs
#!/usr/bin/env node // Harbour Almanac's fetcher (contract 1): the kitchen-sink example of // kanboombo.com/developers/plugins/examples/harbour-almanac/. The harbour and // its data are invented; everything else is how a real plug-in is written. // // Kanboombo downloads the four files plugin.json declares and starts this // with the run context on stdin. It reads them, and prints one record per // line on stdout: // // tide a value per day: high and low water, in metres (observation dates) // catch a value per month: tonnes landed, on the day it was published, // with the month in `period`, and `revises` on a correction // council the harbour council's meetings: scheduled, happened, cancelled; // a meeting that moves keeps its id, so Kanboombo marks it moved // notices notices to mariners: a dated title and an https link // // Exit 0 when the files read as expected, even with nothing new. Exit 2 with // a reason on stderr when a file is not what this plug-in knows (a renamed // column, a status it has never seen): the run fails and nothing is written, // which is better than writing something wrong. // // MIT-0 (LICENSE beside it): copy any of it. import { readFileSync } from "node:fs"; import { CsvError, readTable } from "./csv.mjs"; function fail(reason) { process.stderr.write(`harbour-almanac: ${reason}\n`); process.exit(2); } let context; try { context = JSON.parse(readFileSync(0, "utf8")); } catch (error) { fail(`the run context is not JSON (${error.message})`); } // `today` is the date in the manifest's time zone (Pacific/Auckland), worked // out by Kanboombo. Never use new Date().toISOString() for it: at 08:30 in // Auckland it is still yesterday in UTC. const today = context.today; const DATE = /^\d{4}-\d{2}-\d{2}$/; if (!DATE.test(today ?? "")) fail("the run context has no today"); const addDays = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); /** * The first date worth printing for a series. A first run fills what the * manifest's backfill allows (`backfillFrom`; null means everything). After * that, at least the series' snapshot window, and from its newest stored * record when that is older, so days missed while runs failed are filled. */ function since(series, days) { if (context.backfill) return context.backfillFrom ?? "0000-01-01"; const window = addDays(today, -days); const last = context.series?.[series]?.last ?? null; return last !== null && last < window ? last : window; } /** A declared source's text, from the path the run context gives. Never build the path yourself. */ function source(name) { const file = context.sources?.[name]; if (!file) fail(`the run context names no source called ${name}`); try { return readFileSync(file, "utf8"); } catch (error) { fail(`cannot read the source ${name} (${error.message})`); } } function json(name, label) { try { return JSON.parse(source(name)); } catch (error) { fail(`${label} is not JSON (${error.message})`); } } function table(name, label, columns) { try { return readTable(source(name), label, columns); } catch (error) { if (error instanceof CsvError) fail(error.message); throw error; } } function number(text, where) { const value = Number(text); if (text === "" || !Number.isFinite(value)) fail(`${where}: "${text}" is not a number`); return value; } const print = (record) => process.stdout.write(`${JSON.stringify(record)}\n`); const counts = { tide: 0, catch: 0, council: 0, notices: 0 }; // ── tide: a value series, dated by observation ──────────────────────────── // The table holds the whole year, predictions included. A prediction is not // an observation: anything dated after today would refuse the whole batch. { const from = since("tide", 30); for (const [index, row] of table("tides", "tides.csv", ["date", "high", "low", "status"]).entries()) { const where = `tides.csv line ${index + 2}`; if (!DATE.test(row.date)) fail(`${where}: "${row.date}" is not a date`); if (row.date > today || row.date < from || row.status === "predicted") continue; print({ kind: "value", series: "tide", date: row.date, fields: { high: number(row.high, where), low: number(row.low, where) } }); counts.tide += 1; } } // ── catch: a value series, dated by publication ─────────────────────────── // A month's figure is dated the day it was published, with the month it // describes in `period`. A correction is a new record on its own day, with // `revises` naming the day of the figure it corrects. { const from = since("catch", 120); for (const [index, row] of table("catch", "catch.csv", ["published", "month", "landed_t", "revises"]).entries()) { const where = `catch.csv line ${index + 2}`; if (!DATE.test(row.published) || !/^\d{4}-\d{2}$/.test(row.month)) fail(`${where}: expected a date and a month`); if (row.published > today || row.published < from) continue; if (row.revises !== "" && !DATE.test(row.revises)) fail(`${where}: "${row.revises}" is not a date`); print({ kind: "value", series: "catch", date: row.published, period: row.month, fields: { landed: number(row.landed_t, where) }, ...(row.revises ? { revises: row.revises } : {}), }); counts.catch += 1; } } // ── council: an event series ────────────────────────────────────────────── // Print every meeting in the snapshot window (90 days back, 180 ahead) every // run: one missing from three runs in a row is withdrawn. A meeting that // moves keeps its id on its new date, and Kanboombo marks the old day moved. { const data = json("council", "council.json"); if (!Array.isArray(data?.meetings)) fail("council.json has no meetings list"); const STATES = { scheduled: "scheduled", held: "happened", cancelled: "cancelled" }; const from = since("council", 90); const until = addDays(today, 180); for (const meeting of data.meetings) { const state = STATES[meeting?.status]; if (!state) fail(`council.json: meeting ${meeting?.id} has a status this plug-in does not know, "${meeting?.status}"`); if (typeof meeting.id !== "string" || !/^[a-z0-9-]{1,64}$/.test(meeting.id)) fail(`council.json: a meeting has a bad id, ${JSON.stringify(meeting.id)}`); if (!DATE.test(meeting.date ?? "")) fail(`council.json: meeting ${meeting.id} has no date`); if (meeting.date < from || meeting.date > until) continue; // Only a scheduled event may be dated after today. A meeting held "in the // future" is a broken file; one cancelled ahead of its day is left out // until its day comes (withdrawn after three runs, back as cancelled then). if (meeting.date > today && state === "happened") fail(`council.json: meeting ${meeting.id} was held on ${meeting.date}, after today`); if (meeting.date > today && state === "cancelled") continue; const title = (meeting.note ? `${meeting.title}: ${meeting.note}` : meeting.title ?? "Harbour council meeting").slice(0, 200); print({ kind: "event", series: "council", id: meeting.id, date: meeting.date, state, title, ...(state === "happened" && typeof meeting.berthFee === "number" ? { value: meeting.berthFee } : {}), }); counts.council += 1; } } // ── notices: an item series ─────────────────────────────────────────────── // Items are kept (the series has no snapshot), so a notice the harbour takes // down stays on its day. Links are https, to a host plugin.json declares. { const data = json("notices", "the notices file"); if (!Array.isArray(data?.notices)) fail("the notices file has no notices list"); const from = since("notices", 31); for (const notice of data.notices) { if (!DATE.test(notice?.published ?? "") || typeof notice.id !== "string" || typeof notice.title !== "string") fail(`a notice is missing its date, id or title: ${JSON.stringify(notice)}`); if (notice.published > today || notice.published < from) continue; if (typeof notice.url !== "string" || !notice.url.startsWith("https://")) fail(`notice ${notice.id} has no https link`); print({ kind: "item", series: "notices", date: notice.published, id: notice.id, title: notice.title.slice(0, 200), url: notice.url }); counts.notices += 1; } } process.stderr.write(`harbour-almanac: ${counts.tide} tides, ${counts.catch} catch figures, ${counts.council} meetings, ${counts.notices} notices (today is ${today} in Auckland)\n`);

csv.mjs

A CSV reader with no dependency: quoted fields, either line ending, a byte-order mark. It finds columns by header, so a moved column is still found and a renamed one stops the run, naming it.

plugins/harbour-almanac/csv.mjs
// A CSV reader with no dependency, for Harbour Almanac's tide table and // catch file. Enough of RFC 4180 for files a public body publishes: commas, // double-quoted fields (with "" for a quote and line breaks inside), CRLF or // LF line ends, and a byte-order mark. Every row becomes an object keyed by // the header, so a column that moves is still found; a column that is renamed // or missing is an error that says which, and the fetcher stops on it. // // MIT-0: copy it into your own plug-in (LICENSE beside it). export class CsvError extends Error {} /** Every row of a CSV text as arrays of strings; quotes removed, nothing converted. */ export function parseCsv(text) { const rows = []; let row = []; let field = ""; let quoted = false; let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; for (; i < text.length; i += 1) { const ch = text[i]; if (quoted) { if (ch === '"' && text[i + 1] === '"') { field += '"'; i += 1; } else if (ch === '"') quoted = false; else field += ch; } else if (ch === '"' && field === "") quoted = true; else if (ch === ",") { row.push(field); field = ""; } else if (ch === "\n" || ch === "\r") { if (ch === "\r" && text[i + 1] === "\n") i += 1; row.push(field); rows.push(row); row = []; field = ""; } else field += ch; } if (quoted) throw new CsvError("a quoted field is never closed"); if (field !== "" || row.length) { row.push(field); rows.push(row); } return rows.filter((r) => r.length > 1 || r[0] !== ""); } /** * A CSV with a header row, read as objects. `columns` are the header names the * caller needs; a missing one throws a CsvError naming it and the file. */ export function readTable(text, file, columns) { const [header, ...body] = parseCsv(text); if (!header) throw new CsvError(`${file} is empty`); const names = header.map((name) => name.trim().toLowerCase()); for (const column of columns) if (!names.includes(column)) throw new CsvError(`${file} has no column named ${column}`); return body.map((cells, index) => { if (cells.length !== names.length) throw new CsvError(`${file} line ${index + 2} has ${cells.length} cells, not ${names.length}`); return Object.fromEntries(names.map((name, j) => [name, cells[j].trim()])); }); }

The fixtures

fixtures/sources/ holds the four files of 1 October 2026; context.json pins 08:30 in Auckland, still 30 September in UTC.

plugins/harbour-almanac/fixtures/context.json
{ "now": "2026-09-30T19:30:00.000Z", "today": "2026-10-01" }

records.jsonl holds the 350 records they give, the first run's backfill:

plugins/harbour-almanac/fixtures/records.jsonl
{"kind":"value","series":"tide","date":"2025-12-01","fields":{"high":2.1,"low":0.73}} {"kind":"value","series":"tide","date":"2025-12-02","fields":{"high":2.21,"low":0.62}} {"kind":"value","series":"tide","date":"2025-12-03","fields":{"high":2.32,"low":0.5}}

runs/later/ is a week on (Records › a week later), and runs/renamed-column/ a tide table whose high is now high_water, which must fail:

plugins/harbour-almanac/fixtures/runs/renamed-column/expect.json
{ "exit": 2, "reason": "tides.csv has no column named high" }

The sources come from the day alone (scripts/plugin-fixtures/harbour-almanac.mjs in Kanboombo's repository), and kanboombo.com serves the build day's where plugin.json says, so probe downloads real files.

sh
node kanboombo-plugin.mjs check harbour-almanac
text
✔ harbour-almanac: 350 records, 1 later run, 1 failure case, contract 1 digest 53f07aca1e4d6f74ee24c86f95d9ae3600dfd9b51e1aded934444453b2122323

If your digest differs, your copy is not this one. The data's licence says it is invented: Kanboombo holds it, this page is its terms, and it may be kept.