# Kanboombo Developers > Calendar plug-ins put dated records on the days of Kanboombo's readers: a rate, a meeting, a notice. A plug-in is a folder: a manifest (plugin.json) that says what to download and what it writes, and a fetcher (fetch.mjs) that turns those files into records, one JSON object a line. Kanboombo runs it on schedule in a locked room with no network and keeps every revision. Every page below is Markdown. The tool is one file: https://kanboombo.com/developers/plugins/kanboombo-plugin.mjs (Node 24, no dependencies). The contract is contract 1; the schema for plugin.json is https://kanboombo.com/developers/plugins/plugin.schema.json. An agent publishing a plug-in for a person follows https://kanboombo.com/developers/plugins/AGENTS.md: every step from an empty folder to the Plug-in Store, and what only the person does. The same steps are a skill, kanboombo-plugin: https://kanboombo.com/developers/plugins/skill/SKILL.md. # Kanboombo Developers Build what lands on your readers' days. Start with calendar plug-ins: dated records from sources you trust, checked, approved and kept. You will always need a calendar, and so will the people who read with you. Kanboombo puts what lasts on their days. You can add to it. ## Calendar plug-ins A calendar plug-in puts dated records on your readers' days: a central bank's rate, a council's meetings, a harbour's tides. It is a folder with a manifest and a fetcher. Kanboombo downloads the files you declare, runs your fetcher with no network, checks every record and keeps every revision. - [Read the overview](/developers/plugins/): the folder, and how it runs. - [Start the quickstart](/developers/plugins/quickstart/): a plug-in that passes its checks in ten minutes. - [Download kanboombo-plugin.mjs](/developers/plugins/kanboombo-plugin.mjs): the tool, one file for Node 24. - [Read the manifest](/developers/plugins/reference/manifest/): every field and every limit. ## For agents Every page is Markdown too: add `.md` to its address, or ask for `text/markdown`. [llms.txt](/developers/llms.txt) lists every page; [llms-full.txt](/developers/llms-full.txt) holds them whole. # Put what lasts on your readers' days. A plug-in is a folder: a manifest that says what to download, and a fetcher that turns those files into dated records. Kanboombo runs it with no network. A plug-in is a folder. This is Harbour Almanac's, the example that uses every part of contract 1: ```text harbour-almanac/ plugin.json the manifest: what to download, what to write, when, whose data it is fetch.mjs the fetcher: turns the downloaded files into records, one JSON line each csv.mjs a module fetch.mjs imports; any .mjs of your own may sit beside it fixtures/ copies of your sources and the records they must give, for offline checks listing/ your store icon, with listing.json beside it, added when you publish README.md for people; never part of what a person approves LICENSE MIT-0: copy any of it ``` Four commands of one tool take the folder to the publishing portal, in this order: ```text node kanboombo-plugin.mjs init a folder that passes check before you change a line node kanboombo-plugin.mjs check runs it as the Server will: on your fixtures, no network node kanboombo-plugin.mjs preview shows the rows it puts on each day, as the app draws them node kanboombo-plugin.mjs pack makes the ZIP you upload ``` **[Start the quickstart](/developers/plugins/quickstart/)**, or [download kanboombo-plugin.mjs](/developers/plugins/kanboombo-plugin.mjs): one file for Node 24, nothing to install. This is what the almanac puts on a day: ```day title="Harbour Almanac on Thu 1 Oct, as the app draws it" Thu 1 Oct · today ◌ Harbour council: berth fees · Scheduled · Harbour Almanac High and low tide · High 2.20, Low 0.63 · −3.93% · Harbour Almanac Fish landed · 2026-08 · Landed 399.00 · −14.38% · Last rate · Thu 10 Sep · Harbour Almanac ↗ Tide table for October 2026 published · https://notices.harbour-almanac.example/2026/10/01/1 ↗ Leading light on the east bank shows a reduced range · https://notices.harbour-almanac.example/2026/10/01/2 ``` ## How Kanboombo runs it ```text each minute: is a run due, by your schedule, in your time zone? → Kanboombo downloads the sources plugin.json declares (https only, the hosts you named, public addresses only) → fetch.mjs runs in a locked room: no network, no keys, nothing on disk but its own folder, Node and those files; 120 s and 256 MB at most → every record is checked against contract 1; one bad line refuses the run → the run is written in one transaction, and every change is kept ``` Nothing runs until a person approves one exact version of your code, its [digest](/developers/plugins/publish/updates/#digest). A record is a `value` (numbers on a date), an `event` (scheduled, happened or cancelled) or an `item` (a dated title with an https link), dated as your source dates it. ## Is a plug-in right for this? {#is-it-right} You will always be short of time, and so are your readers. Build a plug-in when it saves them a trip they make again and again, to a public source, for a dated fact they act on. If you are that source, or know it well, build it. It carries dated records from the body that publishes them, under terms that let them be kept and shown with the holder's name. It carries one holder's data (two publishers are two plug-ins), from up to 16 sources into up to 64 series. It is not for personal data, opinion or rankings, things with no date, a source behind a login, key or form, or live data: it runs at most 24 times a day. Before you build, know who publishes the data and whether its terms let it be kept ([Data rights](/developers/plugins/plan/data-rights/)), that each file is at a public https address under 20 MB, what each record's date means, the source's zone and rhythm, who should read it, and what the file looks like when it breaks. ## Use your agent {#agent} `init` writes the road to the store as [AGENTS.md](/developers/plugins/AGENTS.md), also a skill: [SKILL.md](/developers/plugins/skill/SKILL.md). `check --json` and `preview --json` answer in codes. | Agent | Reads | Skills in | |---|---|---| | Claude Code | `CLAUDE.md`, which `init` writes as `@AGENTS.md` | `.claude/skills/`, `~/.claude/skills/` | | Codex | `AGENTS.md`, from the repository root to where you start it | `.agents/skills/`, `~/.agents/skills/` | | Cursor | `AGENTS.md` at the project root; the editor, in folders too | `.agents/skills/`, `~/.cursor/skills/` | | Goose | `AGENTS.md`, then `.goosehints`, in each folder it opens | `.agents/skills/`, `~/.agents/skills/` | ```sh download curl -O https://kanboombo.com/developers/plugins/skill/kanboombo-plugin.zip unzip kanboombo-plugin.zip -d ~/.agents/skills ``` ## Where to go next ### Examples - [Harbour Almanac](/developers/plugins/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. - [DOP Economy](/developers/plugins/examples/dop-economy/): The real one: the Banco Central de la República Dominicana's reference rate for the dollar, read from its own workbook with no dependency, every business day. ### The folder - [plugin.json](/developers/plugins/reference/manifest/): plugin.json, decided in order, then every field from Kanboombo's own schema, every limit, and what changed in contract 1. Unknown keys are refused. - [fetch.mjs](/developers/plugins/build/fetcher/): fetch.mjs reads the run context and your downloaded files, prints one record a line, and stops loudly when a file is not what it knows. - [fixtures/](/developers/plugins/build/fixtures/): Copies of your sources and what they must give, so check runs your plug-in as the Server will, offline. preview draws the days; probe tries real sources. ### How it works - [How plug-ins run](/developers/plugins/concepts/how-plugins-run/): Each minute the Server asks which plug-ins are due, downloads what their manifests declare, runs each fetcher with no network, and writes the run whole. - [Records](/developers/plugins/concepts/records/): What your fetcher prints, one JSON object a line: values, events and items. Their keys and dates, every rule a run is held to, and how each change is kept. - [Dates and time zones](/developers/plugins/concepts/dates-and-time-zones/): Your plug-in's today is the date in its manifest's zone, and a record's date is never converted. The mistakes everyone makes once, and how to avoid them. - [How records look](/developers/plugins/concepts/how-records-look/): How the app draws your records on a reader's day, and the words you choose: names in their language, plain figures, a change with no colour, events first. ### Publish - [Publish](/developers/plugins/publish/): From a plug-in that passes check to one in the Plug-in Store, through kanboombo://publish: by you in the portal, or by your agent with the tool. - [Review and listing](/developers/plugins/publish/review-requirements/): What you give beside your code: five example days, three negative cases, each source's terms, release notes, five attestations, and the listing readers see. - [Versions and updates](/developers/plugins/publish/updates/): A person approves one exact version of your code, named by its digest. What the digest covers, the states of a plug-in, what an update may change, how one ends. - [Data rights and audiences](/developers/plugins/plan/data-rights/): Whose data it is, the name readers see beside it, its terms and whether Kanboombo may keep it; and who may read your plug-in. One holder per plug-in. - [Guidelines](/developers/plugins/guidelines/): The rules every plug-in in the Plug-in Store follows, in numbered sections a reviewer can cite: purpose, quality, naming, safety, privacy, data and commerce. - [Developer Agreement](/developers/plugins/publish/agreement/): What you agree to when your organization publishes calendar plug-ins in the Kanboombo Plug-in Store. Version 1, in plain language. ### Reference - [CLI](/developers/plugins/reference/cli/): Every command of the plug-in tool and its options, generated from the tool itself. One file for Node 24, with no dependencies and no install. - [Submission errors](/developers/plugins/publish/submission-errors/): Every problem a check can find, by its code: what it means and what to do. The same words the tool, the portal and the Server use, from one catalog. Every page is Markdown too: add `.md` to its address, or read [llms.txt](/developers/llms.txt) and [llms-full.txt](/developers/llms-full.txt). # Quickstart 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. ## 1. Download the tool ```sh download 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 ```sh run node kanboombo-plugin.mjs init harbour-tides --timezone Pacific/Auckland ``` ```text output 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](/developers/plugins/#agent). ## 3. Check it ```sh run node kanboombo-plugin.mjs check harbour-tides ``` ```text output ✔ 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: ```csv file=harbour-tides/fixtures/sources/tides.csv 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: ```json file=harbour-tides/plugin.json { "$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: ```js file=harbour-tides/fetch.mjs #!/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: ```sh run exit=1 rm harbour-tides/fixtures/sources/data.csv node kanboombo-plugin.mjs check harbour-tides ``` ```text output 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: ```sh run node kanboombo-plugin.mjs check harbour-tides --update ``` ```text output ✔ harbour-tides: 5 records, contract 1 digest 100cf8a2f84e184343f9afe5bf5d681b9301178c1627e73eba146c046e8e27a7 ``` Five records, not six: the prediction stayed out. ## 5. See your days ```sh run node kanboombo-plugin.mjs preview harbour-tides --days 2026-09-30..2026-10-02 ``` ```text output 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 ```sh run node kanboombo-plugin.mjs pack harbour-tides ``` ```text output ✔ 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](/developers/plugins/build/fixtures/): the portal asks for at least one. [Publish](/developers/plugins/publish/). ## Next [Harbour Almanac](/developers/plugins/examples/harbour-almanac/) uses all of contract 1; [Dates and time zones](/developers/plugins/concepts/dates-and-time-zones/) holds the mistakes everyone makes once. # 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. ```day title="Harbour Almanac on Thu 1 Oct, as the app draws it" Thu 1 Oct · today ◌ Harbour council: berth fees · Scheduled · Harbour Almanac High and low tide · High 2.20, Low 0.63 · −3.93% · Harbour Almanac Fish landed · 2026-08 · Landed 399.00 · −14.38% · Last rate · Thu 10 Sep · Harbour Almanac ↗ Tide table for October 2026 published · https://notices.harbour-almanac.example/2026/10/01/1 ↗ Leading light on the east bank shows a reduced range · https://notices.harbour-almanac.example/2026/10/01/2 ``` ```sh download curl -O https://kanboombo.com/developers/plugins/examples/harbour-almanac.zip unzip harbour-almanac.zip ``` | Series | Kind and dates | Source | What it shows you | |---|---|---|---| | `tide` | value, daily, observation | `tides/{year}.csv` | fields, `fieldNames` in two languages, `change`, a unit, a 30-day snapshot | | `catch` | value, monthly, publication | `catch.csv` | `period`, and `revises` for a corrected figure | | `council` | event | `council.json?as-of={today}` | scheduled, happened, cancelled and moved meetings; a value on an event; a window 90 days back and 180 ahead | | `notices` | item | `notices/{year}-{month}.json` | 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. ```json title="plugins/harbour-almanac/plugin.json" source=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. ```js title="plugins/harbour-almanac/fetch.mjs" source=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. ```js title="plugins/harbour-almanac/csv.mjs" source=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. ```json title="plugins/harbour-almanac/fixtures/context.json" source=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: ```jsonl title="plugins/harbour-almanac/fixtures/records.jsonl" source=plugins/harbour-almanac/fixtures/records.jsonl head=3 {"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](/developers/plugins/concepts/records/#a-week-later)), and `runs/renamed-column/` a tide table whose `high` is now `high_water`, which must fail: ```json title="plugins/harbour-almanac/fixtures/runs/renamed-column/expect.json" source=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 run node kanboombo-plugin.mjs check harbour-almanac ``` ```text output ✔ 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. # DOP Economy The real one: the Banco Central de la República Dominicana's reference rate for the dollar, read from its own workbook with no dependency, every business day. DOP Economy is Kanboombo's own plug-in, running on Kanboombo's Server today. It reads the workbook in which the Banco Central de la República Dominicana publishes the dollar's reference rate, buy and sell, and puts each business day's rate on its day. It is small on purpose: one source, one series, two files of code, nothing to install. ```day title="DOP Economy on Mon 28 Sep, as the app draws it" Mon 28 Sep Dollar · Buy 59.27, Sell 59.56 · −0.15% · Banco Central ``` ```sh download curl -O https://kanboombo.com/developers/plugins/examples/dop-economy.zip unzip dop-economy.zip ``` ## plugin.json ```json title="plugins/dop-economy/plugin.json" source=plugins/dop-economy/plugin.json { "contract": 1, "id": "dop-economy", "name": { "es": "Economía RD", "en": "DOP Economy" }, "version": "1.1.0", "timezone": "America/Santo_Domingo", "audience": "public", "schedule": "30 9,17 * * 1-5", "backfill": "all", "sources": [ { "name": "rate", "url": "https://cdn.bancentral.gov.do/documents/estadisticas/mercado-cambiario/documents/TASA_DOLAR_REFERENCIA_MC.xlsx", "maxBytes": 5242880 } ], "series": [ { "key": "usd-dop", "kind": "value", "cadence": "business-day", "dates": "observation", "name": { "es": "Dólar", "en": "Dollar" }, "unit": "DOP", "fields": ["compra", "venta"], "fieldNames": { "compra": { "es": "Compra", "en": "Buy" }, "venta": { "es": "Venta", "en": "Sell" } }, "change": "venta", "snapshot": { "back": "60d", "ahead": "0d" } } ], "limits": { "seconds": 60, "megabytes": 128 }, "licence": { "holder": "Banco Central de la República Dominicana", "attribution": "Banco Central", "terms": "https://opendatacommons.org/licenses/odbl/1-0/", "retain": "yes" } } ``` One workbook of at most 5 MB; one value series whose change readers see on `venta`, with `DOP` read as pesos; observation dates, so a revised rate replaces its day and the old one is kept, and weekends show the rate in force; a 60-day snapshot; 09:30 and 17:30 on weekdays in Santo Domingo, which keeps no daylight time; `public`, so on your days once you install it. ## fetch.mjs and xlsx.mjs ```js title="plugins/dop-economy/fetch.mjs" source=plugins/dop-economy/fetch.mjs #!/usr/bin/env node // DOP Economy's fetcher (contract 1, docs/kanboombo/PLUGIN-CONTRACT.md). // // Reads the run context on stdin, opens the Banco Central's reference-rate // workbook the host downloaded (source "rate"), and prints one value record // per business day the sheet holds: the buy (compra) and sell (venta) rate. // It prints the whole sheet every run; the runner writes only what changed, // so a revised past rate is caught wherever it is. // // Exit 0 when the sheet reads as expected, even with nothing new. Exit 2 when // the workbook is not what this plug-in knows (a renamed sheet, a moved // column): the run then fails with that reason and nothing is written. import { readFileSync } from "node:fs"; import { readSheet, WorkbookError } from "./xlsx.mjs"; const SHEET = "Diaria"; const MONTHS = { ene: 1, feb: 2, mar: 3, abr: 4, may: 5, jun: 6, jul: 7, ago: 8, sep: 9, set: 9, oct: 10, nov: 11, dic: 12, jan: 1, apr: 4, aug: 8, dec: 12, }; function fail(reason) { process.stderr.write(`dop-economy: ${reason}\n`); process.exit(2); } /** "Año" → "ano": header cells compared without accents or case. */ function plain(value) { return String(value ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").trim().toLowerCase(); } function readStdin() { try { return JSON.parse(readFileSync(0, "utf8")); } catch (error) { fail(`the run context is not JSON (${error.message})`); } } const context = readStdin(); const path = context?.sources?.rate; if (!path) fail("the run context names no source called rate"); let rows; try { rows = readSheet(readFileSync(path), SHEET); } catch (error) { fail(error instanceof WorkbookError ? `the workbook changed: ${error.message}` : `cannot read the workbook: ${error.message}`); } const headerIndex = rows.findIndex((r) => { const names = r.cells.map(plain); return ["ano", "mes", "dia", "compra", "venta"].every((h) => names.includes(h)); }); if (headerIndex < 0) fail(`sheet ${SHEET} has no header row with Año, Mes, Día, Compra and Venta`); const header = rows[headerIndex].cells.map(plain); const col = Object.fromEntries(["ano", "mes", "dia", "compra", "venta"].map((h) => [h, header.indexOf(h)])); const today = typeof context.today === "string" ? context.today : null; const seen = new Set(); let printed = 0; for (const { cells } of rows.slice(headerIndex + 1)) { const year = cells[col.ano]; const month = MONTHS[plain(cells[col.mes]).slice(0, 3)]; const day = cells[col.dia]; const compra = cells[col.compra]; const venta = cells[col.venta]; // Notes and blank rows under the table carry no year and day; skip them. if (!Number.isInteger(year) || !Number.isInteger(day) || !month) continue; if (typeof compra !== "number" || typeof venta !== "number" || !(compra > 0) || !(venta > 0)) continue; const date = `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; if (today && date > today) continue; if (seen.has(date)) fail(`sheet ${SHEET} lists ${date} twice`); seen.add(date); process.stdout.write(`${JSON.stringify({ kind: "value", series: "usd-dop", date, fields: { compra, venta } })}\n`); printed += 1; } if (printed === 0) fail(`sheet ${SHEET} has a header but no rates under it`); process.stderr.write(`dop-economy: ${printed} business days read from ${SHEET}\n`); ``` It finds the header by its names, without accents or case, reads Spanish and English months, never prints a date after today, and stops on a date listed twice. It prints the whole sheet every run, so a revised past rate is caught wherever it is. `xlsx.mjs` reads the workbook with nothing but Node, so the digest covers every line that runs. Run it by hand, as the Server does: ```sh run echo '{"today":"2026-09-30","sources":{"rate":"dop-economy/fixtures/sources/rate.xlsx"}}' | node dop-economy/fetch.mjs 2>/dev/null | tail -n 2 ``` ```text output {"kind":"value","series":"usd-dop","date":"2026-09-25","fields":{"compra":59.277,"venta":59.645}} {"kind":"value","series":"usd-dop","date":"2026-09-28","fields":{"compra":59.2669,"venta":59.5577}} ``` No rate for Thursday 24 September, a holiday, nor the weekend. When the Banco Central renames the sheet, it stops: ```sh run exit=2 echo '{"today":"2026-09-30","sources":{"rate":"dop-economy/fixtures/lab/renamed-sheet.xlsx"}}' | node dop-economy/fetch.mjs ``` ```text output dop-economy: the workbook changed: no sheet named Diaria ``` On the Server that line is the failed run's reason: nothing is written, stored rates stay, and readers see `Couldn't refresh` after the last rate until a run succeeds. ## The fixtures `fixtures/sources/rate.xlsx` holds real rows, 3 August to 28 September 2026 and March 2008, and `records.jsonl` the 60 rates they give. With no `context.json`, `check` runs now: every rate is past, so the day does not matter. `runs/renamed-sheet/` must fail; `lab/` holds four more workbooks for Kanboombo's own lab. ```sh run node kanboombo-plugin.mjs check dop-economy ``` ```text output ✔ dop-economy: 60 records, 1 failure case, contract 1 digest … ``` ## Its data {#its-data} The holder is the Banco Central de la República Dominicana, shown as `Banco Central`; `terms` is the Open Database License (ODbL) 1.0, under which it publishes the same rate on the Dominican open data portal; `retain` is `yes`. Its listing links the source's page and that licence, so you read both before you install it: ```json title="plugins/dop-economy/listing.json" source=plugins/dop-economy/listing.json { "listing": 1, "plugin": "dop-economy", "publisher": { "domain": "kanboombo.com" }, "tagline": { "en": "The Banco Central's dollar rate, buy and sell, on every day since 1991.", "es": "La tasa del dólar del Banco Central, compra y venta, en cada día desde 1991." }, "description": { "en": "You see the US dollar's reference rate in Dominican pesos, what the Banco Central buys and sells it at, on every business day back to 1991. On a weekend or a holiday you see the rate in force and the day it was set.\n\nDOP Economy reads the Banco Central's own published file twice each business day, at 9:30 and 17:30 in Santo Domingo, and keeps every revision. A higher dollar is never shown as good or bad: you see the change, and you decide what it means.\n\nIt runs on Kanboombo's Server with no network of its own. It reads nothing about you and never sees who installed it.", "es": "Ves la tasa de referencia del dólar en pesos dominicanos, a cuánto lo compra y lo vende el Banco Central, en cada día laborable desde 1991. En un fin de semana o un feriado ves la tasa vigente y el día en que se fijó.\n\nEconomía RD lee el archivo que publica el propio Banco Central dos veces cada día laborable, a las 9:30 y a las 17:30 en Santo Domingo, y guarda cada revisión. Un dólar más alto nunca se muestra como bueno o malo: ves el cambio, y tú decides qué significa.\n\nCorre en el Servidor de Kanboombo, sin red propia. No lee nada sobre ti y nunca sabe quién lo instaló." }, "category": "economy", "keywords": [ "dollar", "peso", "DOP", "exchange rate", "tasa de cambio", "dólar", "Banco Central", "República Dominicana" ], "links": { "privacy": "https://kanboombo.com/privacy/", "support": { "url": "https://kanboombo.com/about/" } }, "sources": [ { "name": "rate", "title": "Tasa de cambio de referencia del dólar", "page": "https://www.bancentral.gov.do/a/d/2538-mercado-cambiario", "licence": { "name": "Open Database License (ODbL) 1.0", "url": "https://opendatacommons.org/licenses/odbl/1-0/" } } ], "examples": [ { "date": "2026-09-28", "caption": { "en": "A business day", "es": "Un día laborable" } }, { "date": "2026-09-27", "caption": { "en": "A Sunday: the rate in force", "es": "Un domingo: la tasa vigente" } }, { "date": "2008-03-04", "caption": { "en": "March 2008", "es": "Marzo de 2008" } } ] } ``` # The manifest, plugin.json plugin.json, decided in order, then every field from Kanboombo's own schema, every limit, and what changed in contract 1. Unknown keys are refused. `plugin.json` is the first thing a reviewer reads and the only thing the Server trusts: your fetcher cannot reach a host you did not declare, nor write a series you did not name. Add `"$schema": "https://kanboombo.com/developers/plugins/plugin.schema.json"` and your editor completes and checks it as you type. Unknown keys are refused at every level, except `$schema`: a misspelt `fieldnames` fails with `PLG-MANIFEST-UNKNOWN-KEY` instead of being quietly dropped. ## Decide it in this order **Who it is.** `id` is 2 to 48 lowercase letters, digits and hyphens, starting with a letter: your folder's name, part of lasting addresses (`kanboombo://plugins/`), never reused, so make it plain and lasting. `name` is per language; `version` is `major.minor.patch`. **When it runs.** `timezone`, a named IANA zone, never an offset ([Dates](/developers/plugins/concepts/dates-and-time-zones/)); `schedule`, a five-field cron in it, at most 24 runs a day; `backfill`, how far back the first run fills: `all`, or a span such as `90d`. **Who may read it.** `audience` is `public`, `private` or `local`: [Audiences](/developers/plugins/plan/data-rights/#audiences). **What it downloads.** At most 16 `sources`, each a lowercase `name` used once and an https `url` with a lowercase host. Set `maxBytes`, at most 20 MB, close to what you see: a far larger file is more likely an error page than news. **What it writes.** 1 to 64 `series`, each one kind of record: | Decide | Choose | |---|---| | `kind` | `value`, numbers about a date; `event`, something that happens on a date, maybe scheduled ahead (a rate decision, with the new rate as its `value`); `item`, a dated document to open | | `dates` | `observation`, the day described, where a changed number is a revision; `publication`, the day published, with `period` and `revises` (most monthly statistics) | | Fields | `fields`, 1 to 16 in the order shown; `fieldNames` per language; `unit`, at most 16 characters (`%` shows changes in basis points); `change`, the field whose change readers see, never coloured good or bad | | `snapshot` | A window, `{ "back": "30d", "ahead": "0d" }`, inside which every run prints the whole truth: for a source that corrects or removes what it published, not for notices that stand; kept inside what the source shows | `cadence` tells readers what to expect and schedules nothing. `links.hosts` lists hosts items may link to besides your sources'; `limits` may lower `seconds` and `megabytes`, never raise them. `licence` says whose data it is: [Data rights](/developers/plugins/plan/data-rights/). ## Mistakes the check catches {#mistakes-the-check-catches} `check` reports the first problem of each part, with its code, so most are fixed in one pass. An offset is not a time zone: ```json error=PLG-MANIFEST-TIMEZONE { "contract": 1, "id": "my-plugin", "name": { "en": "Mine" }, "version": "1.0.0", "timezone": "-04:00", "audience": "private", "schedule": "0 7 * * *", "backfill": "all", "sources": [{ "name": "data", "url": "https://example.com/data.csv" }], "series": [{ "key": "main", "kind": "value", "cadence": "daily", "dates": "observation", "name": { "en": "Main" }, "fields": ["value"] }], "licence": { "holder": "Someone", "terms": null, "retain": "unknown" } } ``` An `http` source fails with `PLG-MANIFEST-SOURCE-URL`; an id with capitals or an underscore with `PLG-MANIFEST-ID`. ## Every field ### PluginManifest {#pluginmanifest} plugin.json: what your plug-in downloads, what it writes, when it runs and who may read it. Unknown keys are refused. | Field | Type | Required | Description | |---|---|---|---| | `$schema` | string; at most 500 characters | | Your editor's schema for plugin.json, such as https://kanboombo.com/developers/plugins/plugin.schema.json. Ignored by Kanboombo. | | `contract` | `1` | yes | The contract this plug-in follows: 1. | | `id` | [PluginId](/developers/plugins/reference/manifest/#pluginid) | yes | The plug-in's id, the same as its folder's name. | | `name` | [PluginNames](/developers/plugins/reference/manifest/#pluginnames) | yes | Its name per language, 80 characters or fewer each. | | `version` | string | yes | major.minor.patch. Raise it with every change you publish. | | `timezone` | string; 1 to 100 characters | yes | A named IANA zone. It decides today, how schedule is read, and how source URLs are filled. | | `audience` | [PluginAudience](/developers/plugins/reference/manifest/#pluginaudience) | yes | Who may read it: public, private or local. | | `schedule` | string; at most 100 characters | yes | Five-field cron, read in the manifest's time zone. | | `backfill` | `"all"` or [PluginWindow](/developers/plugins/reference/manifest/#pluginwindow) | yes | How far back the first run fills: all, or a span such as 5y. | | `sources` | array of [PluginSource](/developers/plugins/reference/manifest/#pluginsource) | yes | The files Kanboombo downloads for each run. At most 16. | | `series` | array of [PluginSeries](/developers/plugins/reference/manifest/#pluginseries) | yes | What it writes. 1 to 64. | | `links` | [PluginLinks](/developers/plugins/reference/manifest/#pluginlinks) | | Where an item's link may point besides your sources' own hosts. | | `limits` | [PluginLimits](/developers/plugins/reference/manifest/#pluginlimits) | | Lower time and memory limits, if you want them. | | `licence` | [PluginLicence](/developers/plugins/reference/manifest/#pluginlicence) | yes | Who holds the data, how it is credited, and whether it may be kept. | ### PluginSource {#pluginsource} One file Kanboombo downloads before each run and hands to your fetcher. https only; the host refuses private addresses and redirects to hosts no source names. {today}, {year} and {month} are filled in the manifest's time zone. | Field | Type | Required | Description | |---|---|---|---| | `name` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | yes | The source's name: the key your fetcher finds it under in the run context's sources. | | `url` | string; at most 500 characters | yes | Where to download it: https, with a lowercase host. | | `maxBytes` | integer; 1 to 20,971,520 | | The largest download you expect, at most 20 MB (the default). A larger file fails the run. | ### PluginSeries {#pluginseries} One kind of record your plug-in writes, and how readers see it. | Field | Type | Required | Description | |---|---|---|---| | `key` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | yes | The series key every record of it names. | | `kind` | [PluginRecordKind](/developers/plugins/reference/manifest/#pluginrecordkind) | yes | value, event or item. | | `cadence` | [PluginCadence](/developers/plugins/reference/manifest/#plugincadence) | yes | How often it has a new record. | | `dates` | [PluginDates](/developers/plugins/reference/manifest/#plugindates) | yes | Whether a record is dated by what it describes (observation) or by when it was published (publication). | | `name` | [PluginNames](/developers/plugins/reference/manifest/#pluginnames) | yes | Its name per language, shown as the row's label. | | `unit` | string; at most 16 characters | | What its numbers are in, such as DOP, m or %. Up to 16 characters. Screen readers say it after the figures; with % a change is shown in basis points and an event's value as a percentage. | | `fields` | array of [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | | A value series' numbers, by key, in the order they are shown. 1 to 16. | | `fieldNames` | object of [PluginNames](/developers/plugins/reference/manifest/#pluginnames) | | A label for each field, per language, for example compra: { es: Compra, en: Buy }. | | `change` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | | The field whose change from the previous record is shown, never coloured as good or bad. | | `snapshot` | [PluginSnapshot](/developers/plugins/reference/manifest/#pluginsnapshot) | | A window each run fully restates: a stored record missing from it for three runs is withdrawn. | ### PluginSnapshot {#pluginsnapshot} Each run's batch is the whole truth for this window. A stored record inside it that is missing from three runs in a row is withdrawn. | Field | Type | Required | Description | |---|---|---|---| | `back` | [PluginWindow](/developers/plugins/reference/manifest/#pluginwindow) | yes | How far before today the window reaches. | | `ahead` | [PluginWindow](/developers/plugins/reference/manifest/#pluginwindow) | yes | How far after today it reaches, for scheduled events. | ### PluginLinks {#pluginlinks} The hosts an item record may link to, besides every source's host. | Field | Type | Required | Description | |---|---|---|---| | `hosts` | array of string | yes | Lowercase host names, such as www.bancentral.gov.do. At most 16. | ### PluginLimits {#pluginlimits} Lower limits than the defaults, if your fetcher needs less. They can never be raised. | Field | Type | Required | Description | |---|---|---|---| | `seconds` | integer; 1 to 120 | | How long a run may take, 1 to 120. Default 120. | | `megabytes` | integer; 16 to 256 | | How much memory a run may use, 16 to 256. Default 256. | ### PluginLicence {#pluginlicence} Who holds the data and what you may do with it. Readers see the attribution beside every record. | Field | Type | Required | Description | |---|---|---|---| | `holder` | string; 1 to 200 characters | yes | Who publishes the data, such as Banco Central de la República Dominicana. | | `attribution` | string; 1 to 40 characters | | The short name shown beside its records, such as Banco Central. | | `terms` | string or null; at most 500 characters | yes | A link to the terms the data is published under, or null when there are none to link. | | `retain` | `"yes"`, `"no"`, `"unknown"` | yes | Whether Kanboombo may keep the records: yes, no (they can be purged) or unknown. | ### PluginId {#pluginid} A plug-in's id: lowercase letters, digits and hyphens, starting with a letter, 2 to 48 characters. It is also its folder's name, and it is never reused. - Type: string - Pattern: `^[a-z][a-z0-9-]{1,47}$` - Example: `dop-economy` ### PluginKey {#pluginkey} A key for a source, a series or a field: lowercase letters, digits, hyphens and underscores, starting with a letter. - Type: string - Pattern: `^[a-z][a-z0-9_-]{0,47}$` - Example: `usd-dop`, `compra` ### PluginNames {#pluginnames} A name in each language the plug-in speaks, keyed by language code. A reader sees their own language, then en, then the first listed. - Type: object of string ### PluginAudience {#pluginaudience} public: anyone signed in, and a Mac's proxy. private: the readers set at host.kanboombo.com (people and organizations). local: runs on your Mac only; refused by the Server until its slice ships. - Type: `"public"`, `"private"`, `"local"` ### PluginCadence {#plugincadence} How often the series has a new record. It tells readers what to expect; it does not schedule anything. - Type: `"business-day"`, `"daily"`, `"weekly"`, `"monthly"`, `"quarterly"`, `"irregular"` ### PluginRecordKind {#pluginrecordkind} value: numbers on a date. event: something scheduled, happened or cancelled. item: a dated link with a title. - Type: `"value"`, `"event"`, `"item"` ### PluginDates {#plugindates} observation: a record's date is the day it describes. publication: the day it was published, with period naming what it describes. - Type: `"observation"`, `"publication"` ### PluginWindow {#pluginwindow} A span relative to the run's today: a number and a unit, d for days or y for years. - Type: string - Pattern: `^[0-9]{1,4}[dy]$` - Example: `60d`, `400d`, `5y` ## Limits {#limits} The checker's numbers, the same in `check` and on the Server: | Limit | Value | What it holds | |---|---|---| | `sources` | 16 | Sources one manifest may declare | | `sourceBytes` | 20,971,520 (20 MB) | The largest download one source may be (`maxBytes`), in bytes | | `sourceUrl` | 500 | Characters in a source's `url` | | `series` | 64 | Series one manifest may declare | | `fields` | 16 | Fields in a value series, and in one value record | | `linkHosts` | 16 | Hosts in `links.hosts` | | `name` | 80 | Characters in a name, in each language | | `unit` | 16 | Characters in a series' `unit` | | `attribution` | 40 | Characters in `licence.attribution` | | `holder` | 200 | Characters in `licence.holder` | | `terms` | 500 | Characters in `licence.terms` | | `lines` | 250,000 | Records one run may print | | `period` | 32 | Characters in a record's `period` | | `id` | 64 | Characters in an event's or an item's `id` | | `title` | 200 | Characters in a `title` | | `url` | 1,000 | Characters in an item's `url` | | `seconds` | 1 to 120; 120 if you set nothing | How long one run may take, in seconds (`limits.seconds`) | | `megabytes` | 16 to 256; 256 if you set nothing | How much memory one run may use, in megabytes (`limits.megabytes`) | ### What the Server holds a run to {#what-the-server-holds-a-run-to} | What | Number | |---|---| | Time and memory | 120 seconds and 256 MB. A lower `limits.seconds` is kept; a lower `limits.megabytes` is not yet enforced on the Server, though `check` enforces both | | Runs a day | At most 24 (`PLG-SCHEDULE-TOO-FREQUENT` at upload) | | A withdrawal | A stored record missing from its snapshot window for 3 runs in a row | | Withdrawals that wait for a person | More than 10 at once, or more than 1% of a window of 100 or more | | Days a reader asks for at once | 62 | | Records one run may print | 250,000 | | The time zone | A named IANA zone, up to 100 characters | ## Contract 1 and its changes {#changelog} Contract 1 only grows: a field, a stated limit or a check with its own code may be added, and nothing a plug-in relies on is taken away or changes meaning. A change that would break a working plug-in is contract 2, run beside contract 1. A check code is never renamed or reused; a retired one stays in [Submission errors](/developers/plugins/publish/submission-errors/). **October 2026.** Contract 1, with DOP Economy on readers' days. Added the same week, changing nothing DOP Economy writes nor its digest: `$schema` ([plugin.schema.json](/developers/plugins/plugin.schema.json)); `links.hosts`; `backfillFrom` in the run context; `fixtures/context.json` and `fixtures/runs//`; the digest leaving out `listing.json`, `listing/`, `review.json`, `.git/`, `.DS_Store`, `AGENTS.md` and `CLAUDE.md`; and a code from one catalog, with its fix and page, on every problem. The tool's versions are in [the CLI](/developers/plugins/reference/cli/#versions). # The fetcher, fetch.mjs fetch.mjs reads the run context and your downloaded files, prints one record a line, and stops loudly when a file is not what it knows. Kanboombo starts `node fetch.mjs` in your folder, writes the run context to its standard input, and reads records from its standard output. That is the whole protocol: files in, records out. The [quickstart](/developers/plugins/quickstart/#4-teach-it-your-file) writes one in twenty lines. ## The run context {#run-context} ```json norun="Kanboombo writes this; you only read it" { "contract": 1, "plugin": "harbour-almanac", "now": "2026-09-30T19:30:00.000Z", "today": "2026-10-01", "backfill": true, "backfillFrom": "2025-10-01", "sources": { "tides": "/sources/tides", "catch": "/sources/catch", "council": "/sources/council", "notices": "/sources/notices" }, "series": { "tide": { "last": null, "count": 0 }, "catch": { "last": null, "count": 0 }, "council": { "last": null, "count": 0 }, "notices": { "last": null, "count": 0 } } } ``` Use `today`, the date in your zone, not `now`, the moment in UTC. Read each source at the path `sources` gives (under `/sources/` on the Server, your fixture under `check`); never build one. Under `check`, `now` and `today` come from `fixtures/context.json`, and each `fixtures/runs/` runs with `backfill` false. ### PluginRunContext {#pluginruncontext} What the fetcher reads on stdin. sources maps each declared source's name to a file it may read. | Field | Type | Required | Description | |---|---|---|---| | `contract` | `1` | yes | The contract the run follows: 1. | | `plugin` | [PluginId](/developers/plugins/reference/manifest/#pluginid) | yes | Your plug-in's id. | | `now` | string; at most 40 characters | yes | The moment the run started, in UTC (ISO 8601). | | `today` | [PluginDate](/developers/plugins/concepts/records/#plugindate) | yes | Today's date in your manifest's time zone. Nothing you print may be dated after it, except a scheduled event. | | `backfill` | boolean | yes | True on the first run, when nothing is stored yet: print the whole history your backfill allows. | | `backfillFrom` | [PluginDate](/developers/plugins/concepts/records/#plugindate) or null | | The first date a backfill fills, from your manifest's backfill span; null for all. | | `sources` | object of string | yes | Each declared source's name and the file you may read it from. Never build these paths yourself. | | `series` | object of [PluginSeriesContext](/developers/plugins/build/fetcher/#pluginseriescontext) | yes | What is already stored for each series. | ### PluginSeriesContext {#pluginseriescontext} What is stored for one series before this run. | Field | Type | Required | Description | |---|---|---|---| | `last` | [PluginDate](/developers/plugins/concepts/records/#plugindate) or null | yes | The date of its newest stored record on or before today, or null. | | `count` | integer; at least 0 | yes | How many of its records are stored. | ## The rules it lives by {#rules} - **Date by `today`**, never `new Date()`; nothing after it but a scheduled event. - **Records on stdout, everything else on stderr**, which readers never see; a failed run's last stderr line (at most 300 characters) is its reason. - **Exit 0 when the file reads as expected**, even with nothing new; **exit 2 when it is not what you know** (a renamed sheet, a missing column, a new status), with one line saying why. Nothing is written, and readers see it could not refresh: nothing beats something wrong. - **Print the whole truth for your snapshot windows**: a record you stop printing inside one is withdrawn after three runs. More costs only time; Kanboombo writes only what changed. - **The same files give the same bytes**: sort, and print no timestamps or random numbers. The Server runs your fixtures twice. - **Stay in your folder.** Import only your own files and `node:` modules. No network, database, keys or environment beyond the locale; only the files the digest covers are there. A run stops at 120 seconds and 256 MB, or your lower `limits`. ```js norun="the pattern every example uses" function fail(reason) { process.stderr.write(`my-plugin: ${reason}\n`); process.exit(2); } ``` The publishing portal takes no dependencies: copy what you need into your folder as readable `.mjs`. (A plug-in in Kanboombo's own release may declare them in `package.json` with a `package-lock.json`.) ### How far back to print {#how-far-back} On the first run, print what `backfillFrom` allows; the first date is yours to keep. After that, print at least each series' snapshot window, and further back when its newest stored record is older, so days missed while runs failed are filled: ```js norun="from plugins/harbour-almanac/fetch.mjs, which check runs" 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; } ``` ## Read CSV, JSON and XLSX {#reading-files} Both examples carry their readers, MIT-0, to copy. Whatever the format, find columns by name, never position; stop on what you do not know; and convert nothing you need not: a date written `2026-10-01` stays that string, never passed through `new Date()`. - **CSV**: [Harbour Almanac's `csv.mjs`](/developers/plugins/examples/harbour-almanac/#csv-mjs) reads quotes, line breaks inside them, CRLF or LF and a byte-order mark, keys each row by its header, and refuses a file without the columns you need. Convert a decimal comma (`1.234,5`) or another encoding (`new TextDecoder("windows-1252")`) on purpose, and fail on what will not convert. - **JSON**: `JSON.parse` reads; check the shape before you trust it, and map the source's words to the contract's in one table (`held` to `happened`), failing on any word you have never seen. - **XLSX**: a workbook is a ZIP of XML. [DOP Economy's](/developers/plugins/examples/dop-economy/) `xlsx.mjs` reads the ZIP's directory, inflates the parts it needs with `node:zlib`, and returns one sheet as rows. ```js title="plugins/dop-economy/xlsx.mjs" source=plugins/dop-economy/xlsx.mjs head=24 // Reads the cells of one sheet of an .xlsx workbook with nothing but Node. // // A workbook is a zip of XML files. This reads the zip's central directory, // inflates the four parts it needs (the workbook, its relationships, the // shared strings and the one sheet) and returns the sheet as rows of cells. // It imports nothing outside Node, because a plug-in imports nothing outside // its own folder (docs/kanboombo/PLUGIN-CONTRACT.md). import { inflateRawSync } from "node:zlib"; const EOCD = 0x06054b50; const CENTRAL = 0x02014b50; const LOCAL = 0x04034b50; export class WorkbookError extends Error {} /** The zip's entries, by name: { method, compressedSize, offset }. */ function entries(buf) { let end = -1; for (let i = buf.length - 22; i >= Math.max(0, buf.length - 65557); i -= 1) { if (buf.readUInt32LE(i) === EOCD) { end = i; break; } } if (end < 0) throw new WorkbookError("not a zip file (no end of central directory)"); const count = buf.readUInt16LE(end + 10); ``` For a large file (up to 20 MB): read it once, keep only what you print, and print as you go. # Fixtures, check and preview Copies of your sources and what they must give, so check runs your plug-in as the Server will, offline. preview draws the days; probe tries real sources. Fixtures prove to you, the tool and a reviewer that your plug-in works without the network. They are outside the digest; `check` runs them every time. ```text fixtures/ sources/ a real copy of each declared source (tides, or tides.csv) records.jsonl what fetch.mjs prints for them context.json optional: the moment the check runs at runs// optional: a later run, or a source that must fail sources/ the files that differ from fixtures/sources/ context.json its own moment records.jsonl what that run must print, or expect.json { "exit": 2, "reason": "…" }: how it must fail ``` Cut a real source down to the days you want rather than invent one; a source with no fixture fails with `PLG-FIXTURE-SOURCE-MISSING`. What fetch.mjs prints is held to `records.jsonl` byte for byte. Without `context.json`, `check` runs now, and may print differently next week; with it, `today` must be `now`'s date in your zone, which catches the commonest time-zone mistake: ```json file=harbour-almanac/fixtures/runs/wrong-day/context.json { "now": "2026-09-30T10:00:00.000Z", "today": "2026-10-01" } ``` ```sh run exit=1 node kanboombo-plugin.mjs check harbour-almanac ``` ```text output Kanboombo plug-in checks: harbour-almanac ✖ PLG-FIXTURE-CONTEXT-INVALID fixtures/runs/wrong-day: today 2026-10-01 is not 2026-09-30T10:00:00.000Z's date in Pacific/Auckland (2026-09-30) Fix: Write { "now": "2026-10-01T13:30:00Z", "today": "2026-10-01" }, where today is now's date in your time zone. https://kanboombo.com/developers/plugins/publish/submission-errors/#plg-fixture-context-invalid ``` ```sh run rm -r harbour-almanac/fixtures/runs/wrong-day ``` A case with `records.jsonl` is a later day, run with `backfill` false. A case with `expect.json` is a source that must fail, the most useful kind, and the portal asks for at least one: when your source changes shape, your fetcher must stop with a reason, not print nothing or the wrong column. `exit` is the code it must use (any non-zero if left out); `reason`, words its last stderr line must contain. Add one for each way your source has broken, or will: ```json file=harbour-almanac/fixtures/runs/unknown-status/sources/council.json { "asOf": "2026-10-01", "meetings": [{ "id": "meeting-2026-10-1", "date": "2026-10-01", "status": "postponed" }] } ``` ```json file=harbour-almanac/fixtures/runs/unknown-status/expect.json { "exit": 2, "reason": "a status this plug-in does not know" } ``` ## check {#check} ```sh run node kanboombo-plugin.mjs check harbour-almanac ``` ```text output ✔ harbour-almanac: 350 records, 1 later run, 2 failure cases, contract 1 digest … ``` The new case passes: its fetcher stops. Had it printed records, or stopped for another reason, it would fail with `PLG-RUN-EXPECTED-FAILURE`. `check` reads `plugin.json` against contract 1, the folder (named after the id, with a `fetch.mjs`) and its imports; finds a fixture per source; runs `fetch.mjs` on them; checks every record; compares with `records.jsonl`; runs each `runs/`; checks your listing if you have one; and prints the digest. It stops at the first failing step with every problem so far (code, fix, link) and exits 0 or 1: put it in your CI as it is. On your machine the fetcher runs as you, behind a guard: any way Node reaches the network throws (`PLG-RUN-NETWORK`), Node's permission model limits reads to the folder and its sources (`PLG-RUN-READ-OUTSIDE`), and your limits kill it (`PLG-RUN-KILLED`). That is a seat belt for your own code, not a sandbox for anyone else's: other people's code runs only in the Server's [locked room](/developers/plugins/concepts/how-plugins-run/#locked-room). ## preview {#preview} `preview ` draws each day with `pluginDayRows`, the function the app's day uses, so a row reads as it will for a reader ([quickstart](/developers/plugins/quickstart/#5-see-your-days), [How records look](/developers/plugins/concepts/how-records-look/)). `--days A..B` shows up to 62 days (three either side of today by default), `--language es` your Spanish names (dates and figures stay English, as in the app), `--run ` a `runs/`, and `--html ` writes a page. One run cannot show `Moved to …` or a revision: those come from what the Server stored before. ## probe {#probe} ```sh norun="it downloads from the network; check never does" node kanboombo-plugin.mjs probe harbour-almanac ``` `probe` downloads each source once (https, your hosts only, within `maxBytes`), runs the fetcher and checks every record. Use it before you refresh a fixture; it is never part of a check. This site serves the almanac's sources, so you can probe it. ## When check fails {#troubleshooting} Copy all of `check`'s output: its code, fix and link are what an assistant needs ([every code](/developers/plugins/publish/submission-errors/)). The ones everyone meets once: | Code | Nearly always | Do | |---|---|---| | `PLG-RECORD-AFTER-TODAY` | A date from `toISOString()`: in the evening west of UTC, it is already tomorrow | Compare every date with the context's `today` and skip what is later | | `PLG-RUN-NETWORK` | A `fetch()` in the fetcher | Declare the file as a source and read the path the context gives | | `PLG-IMPORT-PACKAGE`, `PLG-IMPORT-OUTSIDE` | An npm package, or `../shared/` | Copy the code into the folder as readable `.mjs`, as Harbour Almanac does with [its CSV reader](/developers/plugins/build/fetcher/#reading-files) | | `PLG-RUN-FAILED` | An exit you did not mean | Read the last line it wrote to stderr | | `PLG-MANIFEST-UNKNOWN-KEY` | A misspelt key | Fix the key the message names | # How a plug-in runs Each minute the Server asks which plug-ins are due, downloads what their manifests declare, runs each fetcher with no network, and writes the run whole. Kanboombo's Server runs your folder on your schedule, in your time zone, for as long as a person's approval holds. A plug-in is built to be unable to do harm, not trusted not to: a reviewer reads every host it may reach, nothing can leave its room, and the same files always give the same records. The cost: no signing in to an API or paging through one; a source is a file at an address you can write down ahead. ## When a run is due Nothing runs until a person approves one exact version of your code, its [digest](/developers/plugins/publish/updates/#digest); before every run the Server hashes your files again, and runs nothing that is not approved. Each minute it asks which plug-ins are due: a time your `schedule` allows has passed since the last attempt, on your zone's clock (Harbour Almanac's `15 6,18 * * *` is shown as "Every day at 06:15 and 18:15, Auckland"). Missed times run once. Approve, Resume and the first run start at once. A failed run is tried again thirty minutes later, then waits for its next time. Runs go one at a time, never beside a deploy, so a due run may wait its turn. ## The downloads {#downloader} Your code never touches the network. Before it starts, Kanboombo downloads each declared source in order, with `{today}`, `{year}` and `{month}` filled: - **https only**, no name or password in the address, to a host your sources name (`links.hosts` is for item links, not downloads); - **public addresses only**: every address the name resolves to is checked, loopback, private, link-local, shared, multicast and reserved ones are refused, and the connection goes to the checked address and nowhere else; - **at most five redirects**, each to your sources' hosts, checked again; - **a `200` of at most `maxBytes`** (never over 20 MB) within 60 seconds, kept only when whole. Each request says `Kanboombo plug-ins (+https://kanboombo.com)`. One failed download fails the run (`Could not download tides: …`), and nothing is written. ## The locked room {#locked-room} Your fetcher runs as `node fetch.mjs` in a room the operating system builds for one run: a throwaway user owning nothing; a network of its own that reaches nothing, with no internet socket; an empty root holding, read only, your approved files at `/plugin`, the downloads at `/sources/` and Node, plus a private temporary folder. Only files the digest covers are copied in (with `node_modules/` when a `package-lock.json` made it), so fixtures and READMEs cannot be imported. There is no database, key or secret: the environment is `LANG=C.UTF-8` and what the system always sets (`PATH`, `USER`). It stops at 120 seconds (or your lower `limits.seconds`) and 256 MB (a lower `limits.megabytes` is not yet enforced here), with at most 32 tasks and 64 MB of output, and only the system calls a service needs. Node's permission model reads only your code and sources, writes only the temporary folder, and allows no child process, worker, native addon or WASI. When the run ends, its code, downloads, output and context are deleted. It sees only the [run context](/developers/plugins/build/fetcher/#run-context): never who installed or reads it, nor how many, nothing of any account or anyone's days, no key, token or Server address. It runs once for all readers. ## The write Every line is checked against the approved manifest, and one bad line refuses the batch ([Records](/developers/plugins/concepts/records/#rules)); a batch that passes is written in one transaction. Each run is kept with its version, digest, Node version, times, counts and sources; the host shows the last five. ## When a run fails {#when-a-run-fails} A download fails, your fetcher exits non-zero, runs past its time or memory, prints over 64 MB, or the batch breaks a rule. Nothing is written and stored records stay. The reason, at most 300 characters, is kept: your last stderr line (or the error, never its stack), a sentence such as `It ran past its 30 s limit and was stopped.`, or the broken rule and line. The plug-in shows Failing at the host, readers see `Couldn't refresh` after your newest record, and the next success clears it. So when your source is not what you expect, exit `2` with one plain line: a failed run beats wrong records. > [!WARNING] > Exit `0` with nothing printed only when your source truly has nothing. An > empty batch is a success, and for a series with a snapshot window it says > every stored record in the window has gone. ## No personal data {#personal-data} A record is shown to every reader and kept for years, so it holds no personal data: no private person's name, email, telephone or identity number, nothing that identifies someone who has not chosen to be public. Leave such columns out even when your source publishes them; your organization states this for every version. ## Kanboombo's own sections Sections marked **Official** in the store are Kanboombo's own code, run outside the locked room with the network and keys they need, each listing saying what it reaches; none of this is relaxed for yours. DOP Economy is held to all of it. # Records What your fetcher prints, one JSON object a line: values, events and items. Their keys and dates, every rule a run is held to, and how each change is kept. A record is one JSON object on a line of your fetcher's output, in a series your manifest declares, of that series' kind. The examples are Harbour Almanac's, checked against [its manifest](/developers/plugins/examples/harbour-almanac/) on every build. | Kind | What it is | Its key | |---|---|---| | `value` | Numbers on a date | series and date, and `period` when dated by publication | | `event` | Something scheduled, that happened, or that was cancelled | series, date and `id` | | `item` | A dated title with an https link | series, date and `id` | ## Values ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-01 {"kind":"value","series":"tide","date":"2026-10-01","fields":{"high":2.2,"low":0.63}} {"kind":"value","series":"catch","date":"2026-08-10","period":"2026-07","fields":{"landed":466}} {"kind":"value","series":"catch","date":"2026-09-10","period":"2026-07","fields":{"landed":461.6},"revises":"2026-08-10"} ``` `fields` holds 1 to 16 finite numbers, each a field the series declares; one left out is not drawn. An **observation** date is the day described: when the source changes that day's number, print the new one under the same key. A **publication** date is the day published, `period` says what it covers (`2026-07`, `2026-Q3`), and a correction is a new record on its own day with `revises` naming the date it corrects: July's first figure stays on 10 August, the day it was known. Use observation for rates and readings, publication for statistics revised in later releases. `period` and `revises` on an observation refuse the batch. ## Events and items ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-01 {"kind":"event","series":"council","id":"meeting-2026-07-1","date":"2026-07-02","state":"happened","title":"Harbour council: berth fees","value":11.5} {"kind":"event","series":"council","id":"meeting-2026-10-3","date":"2026-10-15","state":"scheduled","title":"Harbour council meeting"} {"kind":"item","series":"notices","date":"2026-10-01","id":"nm-2026-10-01-1","title":"Tide table for October 2026 published","url":"https://notices.harbour-almanac.example/2026/10/01/1"} ``` - An event's `state` is `scheduled`, `happened` or `cancelled`; when it takes place, print it again with the same id and date as `happened`. Its `value`, optional, is a number it decided, in the series' `unit`. - `id` is 1 to 64 characters without spaces, the same every run: the source's own, or made from something that stays when the event moves. - **An event's id on a new date means it moved**, and the old date reads `Moved to Thu 22 Oct`. Print each id once a run. An item's id on another date is another record. - A `title` is plain text, at most 200 characters, never markup; an item needs one. An item's `url` is https, to a source's host or one `links.hosts` declares. Anywhere else refuses the batch: ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-01 error=PLG-RECORD-LINK {"kind":"item","series":"notices","date":"2026-10-01","id":"nm-2026-10-01-1","title":"Tide table for October 2026 published","url":"https://example.com/tides"} ``` ## The rules every batch is held to {#rules} Nothing is after `today` in your zone, except a `scheduled` event ([predictions](/developers/plugins/concepts/dates-and-time-zones/#predictions-are-not-observations)). Every date is a real `YYYY-MM-DD`. Each kind has its own keys, and any other is refused, so a misspelt `feilds` never passes quietly. A run prints at most 250,000 records. The Server reads the whole batch against the approved manifest before writing any: one bad line refuses every line, and the run fails with its reason. Here two lines share a key: ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-01 error=PLG-RECORD-REPEATED {"kind":"value","series":"tide","date":"2026-10-01","fields":{"high":2.2,"low":0.63}} {"kind":"value","series":"tide","date":"2026-10-01","fields":{"high":2.21,"low":0.63}} ``` ## Revisions and withdrawal {#revisions} Your fetcher needs no memory: print what the source shows now. In the transaction that writes the run, the Server compares it with what it stored, and nothing a reader once saw is overwritten without a trace. | The run prints | It becomes | |---|---| | A new key | A new record | | A stored key, same content | Nothing | | A stored key, different content | A revision: the new content, the old kept beside it | | A stored event `id` on another date | The old date marked moved to the new one | | A withdrawn key | The record back, as a revision | Content is a value's fields and `revises`, an event's state, value and title, an item's title and link. Each record keeps the run, version and digest that last wrote it. ### Snapshot windows {#snapshot-windows} A source also changes its mind by leaving things out. Kanboombo notices only inside a series' `snapshot`, where your run is the whole truth: `{ "back": "30d", "ahead": "0d" }` is every record from 30 days ago to today. Inside it, a stored record a successful run does not print counts one miss (failed runs count nothing); printed again, its count starts over; on the third miss in a row it is **withdrawn**, hidden with its history kept, and it comes back as a revision if shown again. A moved event is not counted on its old date. Outside a window, or with none, nothing is counted missing: Harbour Almanac's notices have none on purpose. Keep the window inside what your source shows ([why](/developers/plugins/concepts/dates-and-time-zones/#keep-your-window-inside-your-file)). ### Many at once wait for a person {#held} A damaged file can look like a source that withdrew a great deal. When one run would withdraw more than 10 records of a series, or more than 1% of a window holding 100 or more, none are withdrawn: the rest is written, the plug-in shows `Held` with the first missing dates, and a person chooses **Accept removals** or **Keep them** (their count starts over). Until then they stay on readers' days. ## Harbour Almanac, a week later {#a-week-later} `fixtures/runs/later/`, 8 October, corrects a provisional tide, records a meeting's outcome and moves another, three revisions: ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-08 {"kind":"value","series":"tide","date":"2026-09-28","fields":{"high":2.46,"low":0.35}} {"kind":"event","series":"council","id":"meeting-2026-10-1","date":"2026-10-01","state":"happened","title":"Harbour council: berth fees","value":11.75} {"kind":"event","series":"council","id":"meeting-2026-10-3","date":"2026-10-22","state":"scheduled","title":"Harbour council meeting"} ``` A faulty reading gone from inside the window counts one miss. Records stay when a reader turns a plug-in off or a person removes it; only a [purge](/developers/plugins/plan/data-rights/#purge) deletes them. ## Every field {#every-field} ### PluginRecord {#pluginrecord} One line your fetcher prints: a value, an event or an item. One of: [PluginValueRecord](/developers/plugins/concepts/records/#pluginvaluerecord), [PluginEventRecord](/developers/plugins/concepts/records/#plugineventrecord), [PluginItemRecord](/developers/plugins/concepts/records/#pluginitemrecord). ### PluginValueRecord {#pluginvaluerecord} Numbers on a date. | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"value"` | yes | value | | `series` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | yes | A value series the manifest declares. | | `date` | [PluginDate](/developers/plugins/concepts/records/#plugindate) | yes | The date it describes (observation) or was published (publication). Never after today. | | `period` | string; at most 32 characters | | For publication dates: what it describes, such as 2026-08 or 2026-Q3. Empty for observations. | | `fields` | object of number | yes | The series' fields and their numbers. Finite numbers only. | | `revises` | [PluginDate](/developers/plugins/concepts/records/#plugindate) | | For publication dates: the date of the earlier record this one corrects. | ### PluginEventRecord {#plugineventrecord} Something scheduled, that happened, or that was cancelled. | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"event"` | yes | event | | `series` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | yes | An event series the manifest declares. | | `id` | string; 1 to 64 characters | yes | Your id for the event, stable across runs: the same id on a new date moves it. 1 to 64 characters, no spaces. | | `date` | [PluginDate](/developers/plugins/concepts/records/#plugindate) | yes | The day it happens. Only a scheduled event may be after today. | | `state` | [PluginEventState](/developers/plugins/concepts/records/#plugineventstate) | yes | scheduled, happened or cancelled. | | `value` | number | | A number it decided, such as a rate. Optional. | | `title` | string; at most 200 characters | | Plain text, 200 characters or fewer. Never markup. | ### PluginItemRecord {#pluginitemrecord} A dated link with a title, such as a press release. | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"item"` | yes | item | | `series` | [PluginKey](/developers/plugins/reference/manifest/#pluginkey) | yes | An item series the manifest declares. | | `date` | [PluginDate](/developers/plugins/concepts/records/#plugindate) | yes | The day it was published. Never after today. | | `id` | string; 1 to 64 characters | yes | Your id for it, stable across runs. 1 to 64 characters, no spaces. | | `title` | string; 1 to 200 characters | yes | Plain text, 1 to 200 characters. | | `url` | string; at most 1,000 characters | yes | An https link to a source's host or one links.hosts declares. | ### PluginEventState {#plugineventstate} scheduled may be dated in the future; happened and cancelled may not. - Type: `"scheduled"`, `"happened"`, `"cancelled"` ### PluginDate {#plugindate} A calendar date, YYYY-MM-DD. It is never converted between time zones. - Type: string - Pattern: `^[0-9]{4}-[0-9]{2}-[0-9]{2}$` - Example: `2026-09-28` # Dates and time zones Your plug-in's today is the date in its manifest's zone, and a record's date is never converted. The mistakes everyone makes once, and how to avoid them. Your plug-in has one time zone, `timezone`, a named IANA zone such as `Pacific/Auckland`: the zone your source publishes in. It decides your today, when your schedule fires and how dates fill your source URLs. It never changes a record's date. ## Your today is your zone's date The [run context](/developers/plugins/build/fetcher/#run-context) gives `today`, your zone's date when the run starts, often not UTC's: ```sh run node -e 'const now = new Date("2026-09-30T19:30:00.000Z"); console.log(now.toISOString().slice(0, 10)); console.log(new Intl.DateTimeFormat("en-CA", { timeZone: "Pacific/Auckland" }).format(now));' ``` ```text output 2026-09-30 2026-10-01 ``` The first line is the mistake everyone makes once: by `toISOString()` it is still 30 September in Auckland until 13:00 (12:00 in winter), and zones west of UTC err the other way. Take the `today` you are given; neither the Server's clock nor your machine's is yours. To count days, count on the calendar date at midnight UTC, where every day has 24 hours; there, and only there, `toISOString()` is right: ```js norun="from plugins/harbour-almanac/fetch.mjs, which check runs" const addDays = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); ``` ## Your schedule fires on your clock | Plug-in | `schedule` | On its clock | In UTC | |---|---|---|---| | Harbour Almanac, `Pacific/Auckland` | `15 6,18 * * *` | 06:15 and 18:15, every day | 17:15 and 05:15 in New Zealand daylight time, 18:15 and 06:15 outside it | | DOP Economy, `America/Santo_Domingo` | `30 9,17 * * 1-5` | 09:30 and 17:30, Monday to Friday | 13:30 and 21:30 all year: no daylight time | Daylight saving moves the UTC time, never yours. A time the clocks skip does not run; a time they repeat runs once, at its first instant. Run after your source publishes: if it posts at 17:00, run at 17:30. `{today}`, `{year}` and `{month}` in a source's `url` turn at your midnight: Harbour Almanac reads the new year's tide table from midnight in Auckland, thirteen hours before UTC's. ## A record's date is never converted The date you print is the date every reader sees, in every zone: Monday 28 September's rate is on Monday 28 September in Tokyo and in Santo Domingo. Date a record as your source does, in your zone; never convert it into UTC and back, and never write a time into a date. A reader in Santo Domingo sees Auckland's 1 October tide once their own 1 October comes: the app draws no value after the reader's today. ## Keep your window inside your file A `snapshot` window counts from today, and a stored record a run stops printing inside it is withdrawn after three runs. If your source does not reach as far back as your window, every record between the two starts goes missing on every run. So Harbour Almanac's tide table for a year starts on 1 December of the year before: one starting 1 January would leave late December out, and by the third run some thirty withdrawals would wait for a person. The same holds ahead: the council's window and its source both reach 180 days. A source split into one file a calendar year never reaches back past 1 January, since a `url` names only this year's (`{year}`). Leave `snapshot` out of such a series: nothing is counted missing, a record printed with new content is still revised, and the first run fills back only to 1 January. ## Predictions are not observations A value is something known, so never after today: Harbour Almanac's fetcher skips the tide table's predictions, since one would refuse the batch with [`PLG-RECORD-AFTER-TODAY`](/developers/plugins/publish/submission-errors/#plg-record-after-today). Only a `scheduled` event may sit on a future day. A meeting that happened or was cancelled is past news, even when cancelled weeks ahead: ```jsonl records manifest=plugins/harbour-almanac/plugin.json today=2026-10-01 error=PLG-RECORD-AFTER-TODAY {"kind":"event","series":"council","id":"meeting-2026-11-1","date":"2026-11-05","state":"cancelled","title":"Harbour council meeting"} ``` Harbour Almanac leaves such a meeting out until its day (the stored scheduled one is withdrawn after three runs, and comes back cancelled on its day), and stops on one the source says was held in the future, since that file is broken. # How records look How the app draws your records on a reader's day, and the words you choose: names in their language, plain figures, a change with no colour, events first. A reader who turned your plug-in on finds a bar with its name, and one quiet row a record, on each day that has one. You choose the words; every plug-in is drawn the same flat way, so a reader learns it once. These days are drawn by the app's own code from the examples' fixtures (in Markdown, a line a row). ```day title="Harbour Almanac on Thu 1 Oct, as the app draws it" Thu 1 Oct · today ◌ Harbour council: berth fees · Scheduled · Harbour Almanac High and low tide · High 2.20, Low 0.63 · −3.93% · Harbour Almanac Fish landed · 2026-08 · Landed 399.00 · −14.38% · Last rate · Thu 10 Sep · Harbour Almanac ↗ Tide table for October 2026 published · https://notices.harbour-almanac.example/2026/10/01/1 ↗ Leading light on the east bank shows a reduced range · https://notices.harbour-almanac.example/2026/10/01/2 ``` ## The words you choose {#names} Your plug-in's `name`, each series' `name` and each name in `fieldNames` are per language, keyed by a two- or three-letter code (optionally with a region), at most 80 characters each. A reader sees their language, then `en`, then the first you listed. The app looks up two letters, so key `es`, not `es-DO`, and give `en`. The same day, for a Spanish reader: ```day title="Almanaque del puerto on Thu 1 Oct, as the app draws it" Thu 1 Oct · today ◌ Harbour council: berth fees · Scheduled · Harbour Almanac Pleamar y bajamar · Pleamar 2.20, Bajamar 0.63 · −3.93% · Harbour Almanac Pesca desembarcada · 2026-08 · Desembarcado 399.00 · −14.38% · Last rate · Thu 10 Sep · Harbour Almanac ↗ Tide table for October 2026 published · https://notices.harbour-almanac.example/2026/10/01/1 ↗ Leading light on the east bank shows a reduced range · https://notices.harbour-almanac.example/2026/10/01/2 ``` - **A series name** labels the row: name what the reader sees, "High and low tide", not where it came from. - **Field names** say what each figure is; a field without one shows its key. - **A unit**, at most 16 characters as the source writes it (`m`, `DOP`, `%`), is not drawn: it decides how a change is measured and how a screen reader says the number (`DOP`, `USD`, `EUR` and `%` as words, others as written). - **Keys** (`tide`, `high`) are lowercase letters, digits, hyphens and underscores, kept once records are stored. - **The attribution** ends your rows, at most 40 characters, or the `holder`. - **Titles** are plain text, at most 200 characters, never markup, in the source's words and language; the day already says the date. An item's is its link; an event without one shows the series' name. The app's own words (`Scheduled`, `Last rate`, `Rate of`, `Moved to`, `Cancelled`) and dates are in English today. ## Values A value row is the series' name (with `period` when dated by publication: `Fish landed · 2026-08`), the figures in the order of `fields`, each named under it and rounded to two decimals (`59.5577` reads `59.56`), then the change of your `change` field (or the first) from the previous record, a signed percent or, for a `%` unit, basis points. It is the same quiet grey either way: a higher dollar is a weaker peso, and Kanboombo does not decide which is good news. Your attribution sits under it. From its first record to the reader's today, a value series fills every day. Before your newest record, a day with none shows the value in force, lighter, with no change, and `Rate of `: ```day title="DOP Economy on Sat 26 Sep, as the app draws it" Sat 26 Sep Dollar · Buy 59.28, Sell 59.65 · Rate of Fri 25 Sep · Banco Central ``` After your newest record, each day shows it with `Last rate · ` (monthly series too, and the words may change); after the reader's today, nothing. ```day title="DOP Economy on Wed 30 Sep, as the app draws it" Wed 30 Sep Dollar · Buy 59.27, Sell 59.56 · −0.15% · Last rate · Mon 28 Sep · Banco Central ``` ## Events and items Events lead their day, then values and items, each in your manifest's order of series. | State | The row | |---|---| | `scheduled` | `◌` and the title, with `Scheduled · ` under it | | `happened`, with a `value` | The series' name and the value: two decimals, or `5.50%` for a `%` unit | | `happened`, no `value` | The title | | moved | On the old date, the title and `Moved to ` | | `cancelled` | The title and `Cancelled` | Only events are drawn after the reader's today: scheduled ones, and the old date of one that moved. ```day title="Harbour Almanac on Thu 15 Oct, as the app draws it" Thu 15 Oct ◌ Harbour council meeting · Scheduled · Harbour Almanac ``` A happened event's change is against the previous happened event (`+25 bp`, with `Was 5.25%` under it, or `held`); with no earlier value, the value alone: ```day title="Harbour Almanac on Thu 2 Jul, as the app draws it" Thu 2 Jul Harbour council · 11.50 High and low tide · High 2.39, Low 0.38 · −1.65% · Harbour Almanac Fish landed · 2026-05 · Landed 528.00 · Rate of Wed 10 Jun · Harbour Almanac ``` A cancelled meeting keeps its day: ```day title="Harbour Almanac on Thu 6 Aug, as the app draws it" Thu 6 Aug Harbour council meeting: lapsed for want of a quorum · Cancelled High and low tide · High 1.87, Low 0.86 · +1.08% · Harbour Almanac Fish landed · 2026-06 · Landed 488.90 · Rate of Fri 10 Jul · Harbour Almanac ``` An item is its title, a link to its `url`, on its own day only, with no figure and no attribution. ## When your plug-in falls behind Days after your newest record end with: | Your plug-in | Those days say | |---|---| | Running | `Last rate · · ` | | Failing: the last run failed after the last success | `Couldn't refresh · from ` | | The reader's app cannot reach Kanboombo | `Couldn't reach Kanboombo · from ` | | Paused, or a new version waits for approval | `Not updating · from ` | A plug-in with no records has no section, except on today when its first run failed or Kanboombo cannot be reached. A screen reader reads a row as one sentence: ```text Dollar, buy 59.27, sell 59.56 pesos, down 0.15 percent, Monday 28 September, Banco Central ``` `preview` draws your own days with this code, in any language: [preview](/developers/plugins/build/fixtures/#preview). # Publish your plug-in From a plug-in that passes check to one in the Plug-in Store, through kanboombo://publish: by you in the portal, or by your agent with the tool. > [!IMPORTANT] > The publishing portal is open to invited organizations first. Any > organization can verify its domain, upload and run the checks; submitting > waits for an [invitation](#invitation). The portal is `kanboombo://publish`, in the app and on the web. Walk these steps yourself, or let [your agent](#agent) walk most of them; a reviewer may ask for changes, and you upload again. ## Ask to be invited {#invitation} Once you have named your domain, the portal offers **Ask to be invited**: say in a line what you will publish. Your agent can ask, with no password or token: ```sh norun="a request to Kanboombo's live API" curl -X POST https://api.kanboombo.com/api/developers/invitations \ -H 'content-type: application/json' \ -d '{"email":"you@example.org","organization":"Example Org","domain":"example.org","note":"Tide tables and council meetings"}' ``` The answer is the same whether or not the domain is known; nothing is mailed until you are invited; one request per domain is open at a time, five a day per address. The invitation belongs to the domain. ## 1. Your organization and domain {#organization} You publish as an organization, which readers see as the publisher, through an owner or admin with a verified email. Verify its domain with the portal's token, as a DNS record or a file: ```text _kanboombo-verify.example.org TXT "kanboombo-verify=" https://example.org/.well-known/kanboombo-verify.txt ``` An IP address, a name with no dot, a shared host (`github.io`, `vercel.app`) and a domain another organization verified are refused. It is checked every 30 days; after a failed check you have 14 days, told by email, before it lapses, and published plug-ins keep running without the verified mark. Unverified, you can upload and check, not submit. Accept the [Developer Agreement](/developers/plugins/publish/agreement/) once, and again before your next submission after a new version is posted. ## 2. Upload the ZIP {#upload} Upload what [`pack`](/developers/plugins/quickstart/#6-pack-it) makes: your folder under its id, with `.kanboombo-pack.json` (contract, id, version, digest, each file's path, size and SHA-256, the tool's version), without `node_modules/`, `.git/`, `.DS_Store` or earlier packs. | Rule | Limit | |---|---| | Size | The upload 8 MiB; uncompressed 32 MiB; the code the digest covers 1 MiB; compression at most 100 to 1 | | Entries | At most 500 plain files, no links; stored or deflated; no ZIP64, no encryption, nothing before the archive, a central directory that agrees with its entries | | Paths | No `..`, absolute path, `\` or control character; at most 8 folders deep; no two differing only in case | | Code | `.mjs`, `.json`, `.md`, `.txt` and `LICENSE` only; no `node_modules/` or declared dependencies. Fixtures and listing may be any kind | | Quotas | 20 uploads a day per organization; 3 versions being checked and 5 in review at once | No path in the ZIP becomes a path on Kanboombo's disk. Over a quota nothing is stored; the same bytes twice are one version. A new version must be greater than every one you published, the same version with other code is refused, and the same code with a new listing is a listing-only version, its code approval standing. ## 3. Read the checks {#checks} At upload, in seconds, nothing runs: the archive; the manifest (contract 1, an id not reserved or taken, at most 24 runs a day, a terms link, no IP address as a source); what an [update may not change](/developers/plugins/publish/updates/#refused); a code scan (outside or undeclared imports, dynamic imports, network APIs, `eval`, `new Function`, minified code); fixtures and listing. Then the locked room runs your fixtures twice (same output), each broken source (it must fail), your example days, your real sources once, and each listing link. Every problem has a code in [Submission errors](/developers/plugins/publish/submission-errors/); **Copy issues** copies them in `check`'s words. ## 4. Submit, and a person reviews it {#submit} Add your [review information](/developers/plugins/publish/review-requirements/). Submit is refused, with each reason, until it is complete with every attestation, the current agreement is accepted, and your domain is verified and invited. A person at host.kanboombo.com reads the checks, the manifest, every changed file, your example days as the locked room drew them, your listing and review information, then **approves** exactly that (a change since refuses it), **requests changes** or **rejects**, with a reason and a note. You and your owners and admins hear by email and in the portal. A reviewer from your organization does not approve yours when someone else can. ## 5. Publish {#publish} An approved version waits for you, or publishes itself if you said so before submitting. It is live within 15 minutes, and readers move to it at its next run ([Updates](/developers/plugins/publish/updates/)). Any organization may upload and check within the quotas; a verified one may reserve an id; only a verified, invited one may submit and publish. ## With your agent {#agent} Any agent that runs commands can upload, read the checks, fill in review information and submit, without your password ([Use your agent](/developers/plugins/#agent)). You approve it once, by a code, for one organization, and choose whether it may also publish. > [!IMPORTANT] > Five things stay yours, and the agent's token is refused for each: verifying > the domain, accepting the agreement, approving the agent, retiring a plug-in > and, unless you allow it, pressing Publish. ```sh norun="these reach Kanboombo's live API with the agent's token" node kanboombo-plugin.mjs login node kanboombo-plugin.mjs upload bcrd-weekly node kanboombo-plugin.mjs status node kanboombo-plugin.mjs review --file bcrd-weekly/review.json --attest node kanboombo-plugin.mjs submit node kanboombo-plugin.mjs publish ``` `login` shows a code, the agent's name (`--name` changes it) and an address, `kanboombo://publish/agents?code=…`. Open it as an owner or admin, choose the organization, leave **It may also publish** off or turn it on, and **Approve**. The code lasts 10 minutes, and `login` run again within them waits for it; `--server` names another Kanboombo API (https, or this machine). Approve only a code your own agent just showed you: one someone sends you asks for your organization's name on their plug-in, so **Deny** it. `status` says when the locked room is done and what is next, in the portal's words. With no version named, these act on the last upload from the folder you are in, or else from this machine. `AGENTS.md` tells the agent to ask you before `--attest`. `publish` stops with `AGENT_MAY_NOT_PUBLISH` unless you allowed it; if you did, `unpublish ` and `relist ` work too. `retire` answers `AGENT_FORBIDDEN`: retiring is for good, so you do it in the portal. The token reaches only the portal's routes, for that organization, while you are its owner or admin: the steps above, withdrawing and re-running checks, and reading reports, never who sent them. It can never name a domain, ask to be invited or revoke agents, and every upload records the agent's name. Shown once, kept by Kanboombo only as a hash, it lives in `~/.config/kanboombo/plugin-token.json`, readable only by you, and stops at `logout`, at **Revoke** under Agents in the portal, at a new login on that machine, or when its approver stops being owner or admin. # Review information and listing What you give beside your code: five example days, three negative cases, each source's terms, release notes, five attestations, and the listing readers see. A person reviews every plug-in, reading what machines already checked against your output in the locked room. Prepare it while your fixtures are fresh. An optional `review.json` at the top of your folder, outside the digest, fills in the portal's form (`examples`, `negatives`, `sources`, `releaseNotes`, `notes`). DOP Economy's: ```json title="plugins/dop-economy/review.json" source=plugins/dop-economy/review.json { "examples": [ { "date": "2026-09-28", "series": "usd-dop", "expect": { "compra": 59.27, "venta": 59.56 }, "note": "A business day." }, { "date": "2026-09-25", "series": "usd-dop", "expect": { "compra": 59.28, "venta": 59.65 }, "note": "The Friday whose rate is in force over the weekend." }, { "date": "2026-09-23", "series": "usd-dop", "expect": { "compra": 59.35, "venta": 59.79 }, "note": "The business day before the 24 September holiday." }, { "date": "2026-09-15", "series": "usd-dop", "expect": { "compra": 58.76, "venta": 59.08 } }, { "date": "2008-03-04", "series": "usd-dop", "expect": { "compra": 33.85, "venta": 33.98 }, "note": "From the start of the workbook's history, as the listing shows it." } ], "negatives": [ { "kind": "no-record", "date": "2026-09-24", "series": "usd-dop", "why": "Our Lady of Las Mercedes, a public holiday: the Banco Central publishes no rate." }, { "kind": "in-force", "date": "2026-09-27", "from": "2026-09-25", "series": "usd-dop", "why": "A Sunday shows Friday's rate, muted." }, { "kind": "source-fails", "case": "renamed-sheet", "why": "A renamed sheet stops the run with its reason, so nothing wrong is written." } ], "sources": [ { "source": "rate", "holder": "Banco Central de la República Dominicana", "terms": "https://opendatacommons.org/licenses/odbl/1-0/", "basis": "open-licence", "evidence": "datos.gob.do publishes the Banco Central's reference-rate series under the Open Database License 1.0. The current workbook on cdn.bancentral.gov.do states no licence of its own." } ], "releaseNotes": { "en": "DOP Economy in the Plug-in Store: a listing with example days. The rate on your days is the same as before.", "es": "Economía RD en la tienda de complementos: una ficha con días de ejemplo. La tasa en tus días es la misma de antes." }, "notes": "Kanboombo's own plug-in, moving from the release folder to the store (docs/plans/plugin-store.md R5). The code is the 1.1.0 already approved at host › Plug-ins." } ``` ## Five example days {#example-days} Each with its **date**, **series**, what you **expect** (a value's figures, an event's state and title, an item's title) and an optional **note**. Each is checked against what your fixtures print, so you know before a person does. Show each series and each thing your fetcher does: a revision, a scheduled event, a link. Harbour Almanac's 10 September shows August's catch and July's revised: ```day title="Harbour Almanac on Thu 10 Sep, as the app draws it" Thu 10 Sep High and low tide · High 2.37, Low 0.47 · +7.24% · Harbour Almanac Fish landed · 2026-07 · Landed 461.60 · −0.94% · Harbour Almanac Fish landed · 2026-08 · Landed 399.00 · −14.38% · Harbour Almanac ``` ## Three negative cases {#negative-cases} Each is checked like an example day. - **A day with no record**, and why: for DOP Economy, Thursday 24 September 2026, a holiday with no rate. - **A day where a value is in force**, and the date it comes from: DOP Economy's Sunday 27 September shows Friday's rate, muted: ```day title="DOP Economy on Sun 27 Sep, as the app draws it" Sun 27 Sep Dollar · Buy 59.28, Sell 59.65 · Rate of Fri 25 Sep · Banco Central ``` - **A source that fails**: at least one `fixtures/runs//` with an `expect.json` ([Fixtures](/developers/plugins/build/fixtures/)), which `check` already holds to it with [PLG-RUN-EXPECTED-FAILURE](/developers/plugins/publish/submission-errors/#plg-run-expected-failure). ## Each source's terms {#source-terms} For each source: its **holder**, its **terms**, your **basis** and your **evidence** (the licence on the holder's page, the permission you were given). | Basis | It means | |---|---| | `open-licence` | The holder publishes it under an open licence that allows it, such as the ODbL. | | `permission` | The holder gave you permission. Keep it in writing. | | `public-domain` | Nobody holds rights in it. | | `own-data` | Your organization is the holder. | When the terms are unclear: [Data rights](/developers/plugins/plan/data-rights/#unclear). ## Release notes {#release-notes} What changed, in each language your plug-in speaks; every update needs them. Optionally, notes for the reviewer and a video link. ## Attestations {#attestations} Your organization makes five statements about each version; submit is refused until all five are given: | Code | You state that | |---|---| | `A-RIGHTS` | You may publish every source's data this way: its holder's licence allows it, the holder gave you permission, it is in the public domain, or it is your own. | | `A-AUTHORITATIVE` | Each source is the holder's own publication, not a copy of it someone else keeps. | | `A-NO-PERSONAL-DATA` | Your records name no private person and carry nothing that identifies one. | | `A-NO-NETWORK` | Your fetcher reads only the files Kanboombo hands it, and never tries the network. | | `A-NEUTRAL` | Your records report what the source says. Titles inform; they do not persuade, sell or advertise. | A plug-in has no network or keys, so the form never asks for an account, password or key for a reviewer; a plug-in that needs one is not one contract 1 can run. ## The listing {#listing} Readers have a minute to decide whether your plug-in belongs on their days: the listing is that minute. `listing.json` and `listing/icon.png` sit outside the code digest, with a digest and approval of their own; readers see only an approved listing, and `check` holds it to the store's rules ([DOP Economy's](/developers/plugins/examples/dop-economy/#its-data)). | Field | What it holds | |---|---| | `listing`, `plugin`, `publisher.domain` | `1`, your id, your verified domain | | `tagline`, `description` | Per language, English required: a line of 80 characters; plain text of 2,000 | | `category` | `economy`, `public-life`, `faith`, `sports`, `weather`, `science`, `culture` or `work` | | `keywords` | At most 12, each at most 30 characters | | `links` | `privacy`, required; `homepage`, optional; `support`, a `url` or an `email`, required. Links are https | | `sources` | For a declared source: its `title` (120 characters), `page` and `licence` (`name`, 60 characters, and `url`) | | `examples` | 1 to 3 dates, each with an optional `caption` per language (120 characters) | | `listing/icon.png` | A 256 × 256 PNG, at most 64 KB | Example days are drawn from your verified output: a typical day, a quiet one, one that shows what it is for; a date with nothing refuses the listing ([PLG-LISTING-EXAMPLE](/developers/plugins/publish/submission-errors/#plg-listing-example)). Your series and hosts come from the approved manifest, never the listing. Installs show rounded (100+, 1,000+, 10,000+), and not at all below 100. # Versions and updates A person approves one exact version of your code, named by its digest. What the digest covers, the states of a plug-in, what an update may change, how one ends. A person approves one exact set of files, named by its digest; change one and it waits again. `version`, `major.minor.patch`, is the name people see: raise it with every change you publish. ## The digest {#digest} The SHA-256 of every file in your folder that runs, in a fixed order, as 64 lowercase hex characters. It covers every file except: - at the top: `fixtures/`, `listing/`, `listing.json` and `review.json`, which describe your plug-in rather than run it; - anywhere: `node_modules/`, `.git/`, `.DS_Store`, names starting `README`, `AGENTS.md` and `CLAUDE.md`. A symbolic link anywhere refuses the plug-in. What it leaves out is not copied into the locked room, so code cannot import a file nobody approved, and editing words for people or agents never sends your code back. ```sh run node kanboombo-plugin.mjs digest harbour-almanac --files ``` ```text output 53f07aca1e4d6f74ee24c86f95d9ae3600dfd9b51e1aded934444453b2122323 LICENSE csv.mjs fetch.mjs plugin.json ``` Change any of them, even only the version, and the digest changes, though the records do not. ### The algorithm {#algorithm} 1. List the covered files by their paths relative to the folder, with `/` between folders. 2. Sort the paths by their UTF-8 bytes. 3. For each, feed SHA-256 its path, a zero byte, its length in bytes as decimal digits, a zero byte, and its bytes. 4. The digest is the SHA-256 in lowercase hexadecimal. A test vector: ```text file=vector/plugin.json {"contract":1} ``` ```text file=vector/fetch.mjs console.log("hello"); ``` ```text file=vector/README.md Not in the digest. ``` ```text file=vector/fixtures/records.jsonl {"not":"in the digest either"} ``` ```md file=vector/notes/AGENTS.md Nor at any depth. ``` ```sh run node kanboombo-plugin.mjs digest vector --files ``` ```text output 59f6124be3afabb8d74756d91613dce8db981d32f26a2b4578a3ca6f7a072110 fetch.mjs plugin.json ``` If yours differs, compare the order and the lengths (22 and 15 bytes) first. ## Approval and states {#states} A person approves at host.kanboombo.com › Plug-ins, which sets out what the manifest claims (downloads, schedule, storage, licence, readers), the digest and its files, the last five runs and the Node version. Approving names the digest the page showed: if the files changed meanwhile, it is refused. An approved plug-in runs at once, then on its schedule. A `local` one cannot be approved. | State | Runs | Your readers see | How it gets there | What a person can do | |---|---|---|---|---| | Pending | No | For a first version nothing; else the approved records, then `Not updating` | It arrived, or its files changed | Approve, Remove; Pause if an earlier version was approved | | Approved | On its schedule | Its records | Approved or resumed, or a run succeeded while Failing | Pause, Remove | | Failing | Retried after 30 minutes, then on schedule | Its records, then `Couldn't refresh` | A run failed while Approved | Pause, Remove | | Paused | No | Its records, then `Not updating` | A person paused it | Resume, Remove | | Removed | No | Nothing: it leaves the app | A person removed it | Approve | **Resume** returns a paused plug-in to Approved, or to Pending if its files changed. **Removed** keeps the records for if it is approved again. **Held** is not a state but a run waiting for a person ([Records](/developers/plugins/concepts/records/#held)). **Back to Pending**: any change to a covered file, even only `version`. The Server hashes what it is about to run; if that is not the approved digest, nothing runs and the new one waits, while records stay. Once Pending, it stays so until approved, even if the files change back. A version from Kanboombo's own release stops until approved; one published through the portal keeps running while the next is reviewed. Node's version is recorded, not approved: upgrading it stops no plug-in. Write for Node 24, leaning on `node:` modules. ## What an update may change {#refused} An update never removes records, and a new value for a date is a revision. The checks refuse an update that changes what stored records mean: **the time zone**, a series' **kind**, **unit** or **dates**. For those, add a series with a new key, or build a new plug-in. A series removed (its records kept), a field removed, or a new source host is allowed, with a warning for you and the reviewer. Every update carries [release notes](/developers/plugins/publish/review-requirements/#release-notes). A reader installs your plug-in, not a version: a published update is live within 15 minutes, everyone moves to it at its next run, and nobody installs again or loses a record. A newer version marks the one before superseded; the Server keeps each published version's code by digest and hashes it before every run. ## How a plug-in ends {#ending} Yours are under **In the store** on `kanboombo://publish/`: | Action | Who | What happens | |---|---|---| | Withdraw | You | A submitted version leaves review, or an approved one is not published. | | Unpublish | You, or your agent if allowed to publish | Nobody new can install it; it keeps running for those who have it, who see it is unlisted. Records stay. | | List again | You, or your agent if allowed to publish | Back in the store, only after Unpublish: never after a takedown, being taken off the shelf, or Retire. | | Retire | You, typing its id | Runs stop for good; readers keep its records; its id is never used again by anyone; a version in review is withdrawn; your owners and admins are emailed. | | Take off the shelf | Kanboombo | Like Unpublish, but only Kanboombo lists it again. You can still retire it. | | Take down | Kanboombo | Runs stop and a write under way is refused. A reason, a private note and a public message of up to 280 characters, which its readers see, are recorded. Records are hidden, not deleted; it can be restored only for the same digest. | Pressing Unpublish or List again twice does nothing the second time; every move is in the history with who made it; a refusal comes with its code ([In the store](/developers/plugins/publish/submission-errors/#stage-publish)). A taken-down plug-in's records are removed only if its licence says they may not be kept, through the dry-run-first [purge](/developers/plugins/plan/data-rights/#purge). ## Readers' reports {#reports} A reader can report your plug-in from its store page (wrong data, offensive, impersonating, data it may not use, stopped working, or else) with a note and the day. Kanboombo reads every one, and so do you, under **Reports** on your plug-in's page, with what Kanboombo did. Never who sent it. # Data rights and audiences Whose data it is, the name readers see beside it, its terms and whether Kanboombo may keep it; and who may read your plug-in. One holder per plug-in. Every record belonged to someone before it reached your readers. `licence` says who, and what Kanboombo may do with it; `audience`, who may read it. Both are in the [digest](/developers/plugins/publish/updates/#digest) a person approves. ## The licence block ```json norun="a fragment of plugins/harbour-almanac/plugin.json" "licence": { "holder": "Kanboombo (invented example data)", "attribution": "Harbour Almanac", "terms": "https://kanboombo.com/developers/plugins/examples/harbour-almanac/", "retain": "yes" } ``` | Field | What you write | What it does | |---|---|---| | `holder` | Who publishes the data, in full: `Banco Central de la República Dominicana`. Up to 200 characters. | Names the holder. Readers see it beside your records when you give no `attribution`. | | `attribution` | The short name readers know it by: `Banco Central`. Up to 40 characters. Optional. | Shown beside your records on every reader's days. | | `terms` | A link to the terms the data is published under, or `null` when there are none to link. Up to 500 characters. | Tells the person who reviews your plug-in, and later your readers, where the terms are. | | `retain` | `yes`, `no` or `unknown`: whether Kanboombo may keep the records. | Decides whether the records may ever be purged. | An incomplete block fails with [PLG-MANIFEST-LICENCE](/developers/plugins/publish/submission-errors/#plg-manifest-licence). ## retain: may Kanboombo keep the records? {#retain} Kanboombo keeps what your plug-in writes, keeps every revision beside the value it replaced, and never resets them. Records stay when a reader turns your plug-in off and when a plug-in is removed. Only a purge removes them, and a purge is offered only when the licence says the records may not be kept. | Value | It means | What Kanboombo does | |---|---|---| | `yes` | The terms let Kanboombo store the records and keep them. | Keeps every record and every revision. | | `unknown` | Nobody has settled it yet. | Keeps them, and does not offer a purge. A person reads the terms and records the answer. | | `no` | The terms do not let Kanboombo keep the records for good. | Keeps them for now, and offers a purge at host.kanboombo.com. | ### The purge {#purge} A purge is offered at host.kanboombo.com › Plug-ins only while the answer the Server holds for your plug-in is `no`: 1. It runs as a dry run first: it counts the records and revisions it would remove, and removes nothing. 2. To remove them, the person types your plug-in's id. 3. It removes the records and their revisions in one transaction. It cannot be undone. 4. The runs stay. They are the record of what ran on the Server, and they hold versions, digests, counts, sources and errors, not the records. The Server records your manifest's `retain` the first time it registers your plug-in. After that, the answer it acts on is the one a person records at host.kanboombo.com › Plug-ins, once they have read the terms: today a later version's manifest does not change it by itself. Say what you know, and say `unknown` when you do not know. ## One holder, and open licences One licence means one holder's data: the right name on every record, one set of terms and one `retain` for all of it, and a purge that removes no one else's records. Two publishers are two plug-ins; your own data makes you the holder. An open licence settles these questions in advance: the [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), for example, lets anyone keep, share and show the data if they say where it comes from and share an adapted copy of the database under the same licence. Attribution is then the holder's name beside every record and a way to find the terms. Read the licence itself, not a summary, and link the exact version the holder names. Say what is known, then widen it. DOP Economy began `"terms": null`, `"retain": "unknown"` and `private`; once the Banco Central's ODbL publication of the same rate was recorded as the basis, version 1.1.0 linked the ODbL, said `yes` and became `public`. ## When the terms are unclear {#unclear} Ask the holder in writing, before you build: what you will download and how often, that Kanboombo keeps the records and shows them with their name, and whether that is allowed, under which terms. Downloads announce themselves as `Kanboombo plug-ins (+https://kanboombo.com)`. Keep the answer for [review](/developers/plugins/publish/review-requirements/#source-terms). Until then, `"terms": null`, `"retain": "unknown"` and `private` are the honest ones. ## Audiences {#audiences} | `audience` | Who may read it | Where it runs | |---|---|---| | `public` | Anyone signed in, and a Mac's local API, which is served public plug-ins only | On the Server | | `private` | Up to 500 people by account email and 100 organizations, set at host.kanboombo.com | On the Server | | `local` | You, on your Mac | Your Mac only: the Server refuses to approve it | Choose `public` only when the terms let everyone see the data with attribution; until you are sure, `private`. A private plug-in reaches an organization's active members, never its guests or people who have left, keeping a licensed source inside the people its licence covers; it is never served to a Mac's local API, and anyone not on the list is told it does not exist. ### local {#local} Choose a folder in Settings › Developer Mode › **A plug-in folder on your days**, and Kanboombo on your Mac runs it for the days on your screen, marked "On this Mac · not published". Its records are never stored and never sent: not to Kanboombo, not to an organization, not anywhere. Any plug-in's folder shows this way before review: that is how you see your own work ([On your own days](/developers/plugins/reference/cli/#on-your-days)). ### Turning a plug-in on {#turning-a-plug-in-on} May read is not sees: nothing is pushed onto anyone's days. A reader turns a plug-in on under Show sections in any day's "…" menu (the synced preference `plugins..shown`: `true`, `false`, or `null` for no choice). An owner or admin can turn it on for members who made no choice ("On for … members"); a member's own choice wins, and a Mac's local API applies only your own. In the store, `kanboombo://plugins`, you install for yourself and an owner or admin for members (who stay removed if they remove it); a public plug-in is installed by no one until they ask. Hiding or removing a plug-in keeps its records. # Guidelines The rules every plug-in in the Plug-in Store follows, in numbered sections a reviewer can cite: purpose, quality, naming, safety, privacy, data and commerce. A reviewer holds every version to these rules and cites them by number and name, "Guidelines 5, Fair play"; each section keeps its address. ## 1. Purpose {#purpose} 1. A plug-in puts dated records on your readers' days that they would act on, or would otherwise go looking for. 2. Its records come from the body that publishes them, and say what it published. 3. It is not a channel for advertising, opinion or anything without a date ([Is a plug-in right for this?](/developers/plugins/#is-it-right)). ## 2. Quality {#quality} 1. **Correct**: it prints what the source says, no more and no less; its fixtures are real copies of its sources, and its example days are true. 2. **Current**: its schedule follows the source. 3. **Fails loudly**: when a source changes shape it exits non-zero with a reason. One that prints nothing, or guesses, does not pass. 4. **The same every time**: the same files give the same records. ## 3. Naming {#naming} 1. Names say what it carries, in its readers' words, in each language it speaks ([the words you choose](/developers/plugins/concepts/how-records-look/#names)). 2. It impersonates no one: not the source's holder, another publisher or another plug-in. Its publisher is the organization that verified its domain. 3. It never claims to be, be made by or be endorsed by Kanboombo, unless Kanboombo publishes it. Reserved ids (`store`, `publish`, `search`, `markets`, and any starting `kanboombo`, `kb-`, `chk-` or `test-`) are refused ([PLG-ID-RESERVED](/developers/plugins/publish/submission-errors/#plg-id-reserved)), and a name a reader could mistake for another plug-in's must change. 4. Its attribution names the data's holder, and nobody else. ## 4. Safety {#safety} 1. It does not try to reach the network, read outside its folder and sources, start processes or leave its [locked room](/developers/plugins/concepts/how-plugins-run/#locked-room). Trying is a reason to reject it, even when the room stops it. 2. Its code is readable `.mjs`: not minified or obfuscated, with no `eval`, `new Function` or code built at run time. 3. Its titles are plain text, and its links lead to the page where the source publishes that record. ## 5. Fair play {#fair-play} 1. It asks a source for no more than it needs, no more often than it changes, with a `maxBytes` close to what it sends. 2. It behaves the same under review as on readers' days; code that detects a check and does something else is rejected. 3. Its review information is true. 4. It respects every [limit](/developers/plugins/reference/manifest/#limits) by design, not by luck. ## 6. A privacy policy {#privacy-policy} 1. A listed plug-in links its publisher's privacy policy, over https; the listing cannot be submitted without one. 2. The policy says what the publisher does with any data it holds. A plug-in collects nothing from readers and never learns who they are, and the policy should not suggest otherwise. ## 7. Data minimisation {#data-minimisation} 1. It prints only records its readers will see. 2. It declares only the sources it reads, and reads only the columns it needs. 3. Its `backfill` reaches only as far back as is worth having on a reader's days. ## 8. Restricted data {#restricted-data} 1. **No personal data**: nothing that identifies a private person, no names of private people, contact details or identity numbers. 2. **Nothing you may not republish**: only data whose terms let Kanboombo store it and show it to its readers. While the terms are unsettled, its audience is `private` and its `retain` `unknown` ([Data rights](/developers/plugins/plan/data-rights/)). 3. **One holder**, under that holder's terms. ## 9. Commerce {#commerce} 1. No advertising, anywhere. 2. No affiliate links, tracking parameters or referral codes. 3. No checkout and nothing for sale; items link to the source's page for that record, never a storefront. 4. Plug-ins are free to install: no paid plug-ins, no payments. ## 10. Runs a day {#runs-a-day} 1. Its `schedule` runs it at most 24 times a day. 2. Every plug-in shares one queue on the Server, and this keeps it fair; the portal refuses more (`PLG-SCHEDULE-TOO-FREQUENT`). # Developer Agreement What you agree to when your organization publishes calendar plug-ins in the Kanboombo Plug-in Store. Version 1, in plain language. > [!IMPORTANT] > Version 1, in effect from 1 October 2026. Your organization accepts it once, > in the publishing portal, before it submits a plug-in. When a new version is > posted here, you accept it before your next submission. This agreement is between your organization, which publishes plug-ins through its owners and admins, and Batey Business Labs LLC, which makes Kanboombo. "You" is your organization; "we" and "Kanboombo" are Batey Business Labs LLC. ## 1. What you publish {#what-you-publish} You publish plug-ins that follow [contract 1](/developers/plugins/reference/manifest/) and the [Guidelines](/developers/plugins/guidelines/). Every version you submit carries five statements: that you may publish its data, that each source is its holder's own publication, that its records name no private person, that it never tries the network, and that its records report what the source says without selling or persuading. Those statements are part of this agreement, and you make them again with each version. You may publish a source's data only when its holder's licence allows it, the holder has given you permission, it is in the public domain, or it is your own. If that changes, you tell us and stop publishing it. ## 2. Your code stays yours {#your-code} You keep every right in your plug-in: its code, its listing, its icon. You give us a worldwide, non-exclusive, royalty-free licence to store them, to check them and run them on our Servers, to show the listing and its example days in the Plug-in Store, and to keep the records your plug-in wrote. You can end that licence for anything not yet written by retiring the plug-in; the records already written stay, as section 3 says. Code we give you to build with — the `kanboombo-plugin.mjs` tool, the example plug-ins and the code on these pages — is yours to use under the MIT No Attribution licence. ## 3. Records stay on readers' days {#records-stay} A reader who installs your plug-in keeps the records it wrote. When you unpublish, retire or update it, or when we take it down, those records stay, as far as the data's own licence allows (`retain` in your manifest). Your plug-in's id is never given to anyone else. ## 4. How your plug-in runs {#how-it-runs} Your plug-in runs in a locked room on our Servers: no network, no secrets, no access to anything but its own files and the sources it declares. You don't try to get around that, you don't try to learn who installed it, and you don't put anything in it that would harm a reader, our Servers or anyone else. ## 5. Review, takedown and reports {#review} A person at Kanboombo reviews every version before it is published, and may ask you for changes or decline it, with a reason. We may take a published plug-in down at any time — for its data's licence, for harm, for impersonation, for breaking, or because the law requires it — with a short message its readers see. Readers can report a plug-in; you see why, never who. You can always submit a new version. ## 6. Your domain and your name {#your-domain} You publish under a domain you verify, and you keep it verified. If we can no longer verify it, your plug-ins keep running for 14 days while you put the proof back; after that you verify it again before you submit. You don't publish under a name that claims to be someone else, Kanboombo included. ## 7. No fees, no ads, no payments {#no-fees} Publishing is free. Your plug-in doesn't advertise, sell, or ask a reader for payment, and its listing doesn't either. ## 8. Your listing tells the truth {#your-listing} Your listing describes what your plug-in puts on a reader's days, links a privacy policy that is accurate, and gives a support contact that answers. You keep them current. ## 9. What we don't promise {#no-promises} The Plug-in Store, the tools and the Servers are provided as they are. We may change them or stop offering them; when we do, we tell publishers first when we can. To the extent the law allows, neither of us is liable to the other for indirect or consequential losses, and our total liability under this agreement is limited to one hundred US dollars. You are responsible for your right to publish your plug-in's data, and you will cover us for claims that it was not yours to publish. ## 10. Changes to this agreement {#changes} A new version is posted on this page with its date and a note on what changed. You accept it in the publishing portal before your next submission. Until then, your published versions keep running under the version you accepted. ## 11. Ending {#ending} You can stop publishing at any time: retire your plug-ins in the portal. We can end this agreement if you break it, and tell you why. Sections 2 (for records already written), 3 and 9 last after it ends. ## 12. The law {#law} This agreement is governed by the laws of the state in which Batey Business Labs LLC is organized. Questions about it go to Kanboombo through [About](https://kanboombo.com/about/). # kanboombo-plugin.mjs Every command of the plug-in tool and its options, generated from the tool itself. One file for Node 24, with no dependencies and no install. One file holding the Server's checker, the words for every problem and the app's day-drawing code. Its first lines name each file it was built from, with its SHA-256, and its licence, MIT-0. This page is version 1.5.0. ```sh download 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 ``` `node kanboombo-plugin.mjs help ` prints any command's usage. ## Commands ### init {#init} ```text node kanboombo-plugin.mjs init [--kind value|event|item] [--timezone ] ``` Start a plug-in in a new folder named \: a manifest, a fetcher and fixtures that pass check before you change a line. | Option | What it does | |---|---| | `--kind` | The kind of series to start with: value (numbers on a date; the default), event or item. | | `--timezone` | The plug-in's IANA time zone, such as Pacific/Auckland. It decides your plug-in's today. Default: this machine's. | ### check {#check} ```text node kanboombo-plugin.mjs check [] [--update] [--json] ``` Check a plug-in as the Server will: the manifest, its imports, a run of fetch.mjs on fixtures/sources/ with no network, every record, fixtures/records.jsonl and each fixtures/runs/\. Prints the digest an approval covers. | Option | What it does | |---|---| | `--update` | Write what fetch.mjs prints to fixtures/records.jsonl instead of comparing. Read the difference before you keep it. | | `--json` | Print one JSON object instead of text, for your agent: {ok, summary, findings, digest}, each finding with its code, severity, message, fix and docs link, in the words the text uses. It prints it even when check finds problems, and exits 1 then. | ### preview {#preview} ```text node kanboombo-plugin.mjs preview [] [--days ..] [--language ] [--run | --live] [--html | --json] ``` Show the rows your fixture puts on each day, worked out by the code the app draws them with. | Option | What it does | |---|---| | `--days` | The days to show, as YYYY-MM-DD..YYYY-MM-DD, at most 62. Default: three days either side of the fixture's today. | | `--language` | Show your plug-in's own names (its name, series and fields) in this language, as a reader of it sees them. Dates, figures and the app's own words stay in English, as the app draws them today. Default: en. | | `--run` | Preview fixtures/runs/\ instead of the folder's own fixtures. | | `--live` | Preview today's real sources instead of your fixtures: download them once (https, declared hosts only), as probe does. | | `--html` | Also write the days as an HTML page you can open. | | `--json` | Run check first, then print one JSON object instead of text: {summary, cache, findings, digest}, what Kanboombo on your Mac draws your days from. It prints it even when check finds problems, and exits 1 then. | ### probe {#probe} ```text node kanboombo-plugin.mjs probe [] ``` Download the real sources once (https, declared hosts only) and run fetch.mjs on them. It reaches the real source; it is never part of a check. ### pack {#pack} ```text node kanboombo-plugin.mjs pack [] [--out ] ``` Check the plug-in, then write the ZIP the publishing portal takes, with .kanboombo-pack.json inside. Refuses unless check passes. The same files give the same bytes. | Option | What it does | |---|---| | `--out` | Where to write it. Default: \-\.zip beside the folder, never inside it. | ### digest {#digest} ```text node kanboombo-plugin.mjs digest [] [--files] ``` Print the digest an approval covers: SHA-256 over the plug-in's code, without fixtures, listing, README, AGENTS.md or CLAUDE.md. | Option | What it does | |---|---| | `--files` | Also list the files the digest covers, in the order it reads them. | ### login {#login} ```text node kanboombo-plugin.mjs login [--server ] [--name ] ``` Ask to publish for an organization. It prints a code and an address, and waits while a person who owns or runs the organization approves the code in kanboombo://publish. Approved, it keeps the token in ~/.config/kanboombo/plugin-token.json, readable only by you. Run again within 10 minutes, it waits for the same code. | Option | What it does | |---|---| | `--server` | The Kanboombo API to publish to. Default: https://api.kanboombo.com. | | `--name` | How the approval screen names this agent. Default: the agent it runs in and this machine, such as Claude Code on build-box. | ### logout {#logout} ```text node kanboombo-plugin.mjs logout ``` Revoke this agent's token and delete it from this machine. ### upload {#upload} ```text node kanboombo-plugin.mjs upload [] ``` Check and pack the plug-in, then upload it to your organization in the publishing portal. Prints the version, its status and what the upload checks found, in the portal's words. The Server then runs it in its locked room. ### status {#status} ```text node kanboombo-plugin.mjs status [] ``` Show where a version stands: its status, its issues, what the Server's checks found and what to do next. \ is a version id, or a plug-in's folder or id for the last version uploaded from here. Default: the plug-in in the folder you are in, or else your last upload. ### review {#review} ```text node kanboombo-plugin.mjs review [] --file [--attest] ``` Fill in a version's review information from a file: example days, the cases that must show nothing or fail, each source's terms and release notes. | Option | What it does | |---|---| | `--file` | The review information, as JSON. Required. | | `--attest` | Also make the five statements, in your organization's name: you may publish the data, each source is its holder's own, no record names a private person, the fetcher never tries the network, its words inform without selling. Only when they are true and the person you work for agrees. | ### submit {#submit} ```text node kanboombo-plugin.mjs submit [] ``` Submit a version for review by a person at Kanboombo. Refused, with each reason, until the Server's checks passed, the review information is complete, the five statements are made, and your organization is verified, invited and has accepted the current Developer Agreement. ### publish {#publish} ```text node kanboombo-plugin.mjs publish [] ``` Publish a version Kanboombo approved. Only an agent approved with publishing allowed may; otherwise a person presses Publish in kanboombo://publish. ### unpublish {#unpublish} ```text node kanboombo-plugin.mjs unpublish [] ``` Take a published plug-in out of the Plug-in Store: nobody new can install it, it keeps running for readers who have it, and they see it is no longer listed. \ is its id or its folder; default: the folder you are in. Only an agent approved with publishing allowed may. ### relist {#relist} ```text node kanboombo-plugin.mjs relist [] ``` Put a plug-in you unpublished back in the Plug-in Store. Never one Kanboombo took down or took off the shelf, and never one that is retired. Only an agent approved with publishing allowed may. ### retire {#retire} ```text node kanboombo-plugin.mjs retire [] ``` Retiring is for good: its runs stop, readers keep the records it wrote, and its id is never used again. So only a person retires, in kanboombo://publish, typing the id; the API refuses an agent, and this says where the person does it. ### help {#help} ```text node kanboombo-plugin.mjs help [] ``` Show every command, or one command's options. ## Exit codes | Code | Means | |---|---| | `0` | Done: the plug-in passes, or the command did what it says. | | `1` | The plug-in does not pass (its problems are on standard error), or `probe` could not reach a source. `pack` and `upload` send or write nothing. For the publishing commands, also: you are not logged in, Kanboombo could not be reached, or it refused, with its code and message on standard error. | | `2` | The command was not understood: an unknown command or option, a missing argument, a refused `--days`, `--server` or `--file`. | ## Where it runs your code {#where-it-runs-your-code} `check`, `preview`, `pack` and `init` run `fetch.mjs` on your machine, as you, on your fixtures, behind a [guard](/developers/plugins/build/fixtures/#check): a seat belt, not a sandbox. ## On your own days {#on-your-days} Turn on Developer Mode in Settings and choose a folder under **A plug-in folder on your days**: before any review, its section shows "On this Mac · not published", with Run again, What check found, Open the folder and Publish… in its "…" menu. Kanboombo runs `preview --json` on it, with its own Node: ```json norun="its shape: what Kanboombo draws your days from" { "summary": { "id": "dop-economy", "name": { "en": "DOP Economy" }, "source": "Banco Central", "series": [], "freshness": {}, "today": "2026-10-07", "records": 60 }, "cache": { "series": {}, "covered": {}, "freshness": {}, "unreachable": false }, "findings": [], "digest": "5e34581c…" } ``` Nothing is stored and nothing leaves your Mac: the `local` [audience](/developers/plugins/plan/data-rights/#local). ## What reaches Kanboombo, and what it keeps Only `probe` and `preview --live` (your sources) and the publishing commands, `login` to `publish`, use the network. Those talk only to the API `login` was given, send your token nowhere else and follow no redirect ([With your agent](/developers/plugins/publish/#agent)). `login` keeps its token in `plugin-token.json` and `upload` the last version per plug-in in `plugins/.json`, in `~/.config/kanboombo/`, readable only by you; `KANBOOMBO_PLUGIN_HOME` moves it (one per agent on a shared machine), and `logout` revokes and deletes the token. `KANBOOMBO_PLUGIN_NOW`, an ISO time, is the moment `init` writes into `fixtures/context.json`; these docs set `2026-09-30T19:30:00.000Z`. ## Versions {#versions} - **1.5.0.** `check --json`: one object, each finding with its code, fix and docs link. - **1.4.0.** `check` counts the `runs/` it passed; `pack` warns about `init`'s README; `init` keeps acronyms (US). - **1.3.0.** `preview --json`, which your Mac draws your own days from; `preview --live`, on today's real sources; a fetcher in a folder reached through a link (`/tmp` on a Mac) may read its own files. - **1.2.0.** `unpublish`, `relist`, and `retire`, which says where a person retires a plug-in, since only a person may; codes `PLG-LISTING-*` and `PLG-RETIRE-CONFIRM`. - **1.1.0.** Publish with your agent: `login`, `logout`, `upload`, `status`, `review`, `submit` and `publish`. `init` also writes `AGENTS.md` and `CLAUDE.md`. - **1.0.0.** `init`, `check`, `preview`, `probe`, `pack`, `digest` and `help`. Reading outside the folder fails with `PLG-RUN-READ-OUTSIDE`. Contract 1's own changes are in [the manifest](/developers/plugins/reference/manifest/#changelog). # Submission errors Every problem a check can find, by its code: what it means and what to do. The same words the tool, the portal and the Server use, from one catalog. Every problem a check finds carries a code, such as `PLG-RECORD-AFTER-TODAY`, printed with its fix and a link to its entry below, by the tool and by the portal's "Copy issues" alike. This page is generated from the one catalog all of them read (`shared/ts/src/data/plugin-store/checks.ts`), so the words here are the words you saw. A code is never renamed or reused; one no longer used is marked retired and stays, so an old message still finds its page. Codes from the portal's own checks are listed as each one ships. ```text norun="what the tool prints for a problem" Kanboombo plug-in checks: harbour-tides ✖ PLG-RECORD-AFTER-TODAY Line 6 is dated 2026-10-02, after today (2026-10-01) Fix: Usually a time-zone mistake: date your records in the manifest's zone, never with toISOString(). Only a scheduled event may be in the future. https://kanboombo.com/developers/plugins/publish/submission-errors/#plg-record-after-today ``` ## The manifest {#stage-manifest} Found in plugin.json, wherever contract 1 is checked: check, the portal, and the Server before every run. ### PLG-INVALID {#plg-invalid} **Not acceptable to contract 1.** Error; since 2026-10-01. Its message names the field or the line. What to do: Read the sentence: it names the field or line. Every rule is in the Reference. ### PLG-MANIFEST-INVALID-JSON {#plg-manifest-invalid-json} **plugin.json is not JSON.** Error; since 2026-10-01. It says: `plugin.json could not be read as JSON: {detail}` What to do: Check for a trailing comma or an unquoted key. Your editor can validate it against plugin.schema.json. ### PLG-MANIFEST-NOT-OBJECT {#plg-manifest-not-object} **The manifest is not an object.** Error; since 2026-10-01. Its message names the field or the line. What to do: plugin.json must hold one JSON object: { "contract": 1, "id": …, … }. ### PLG-MANIFEST-UNKNOWN-KEY {#plg-manifest-unknown-key} **A key contract 1 does not know.** Error; since 2026-10-01. Its message names the field or the line. What to do: Remove the key, or check its spelling against the Reference. Unknown keys are refused so a typo never passes silently. ### PLG-MANIFEST-CONTRACT {#plg-manifest-contract} **Wrong contract.** Error; since 2026-10-01. Its message names the field or the line. What to do: Set "contract": 1. ### PLG-MANIFEST-ID {#plg-manifest-id} **The id is not allowed.** Error; since 2026-10-01. Its message names the field or the line. What to do: Use 2 to 48 lowercase letters, digits and hyphens, starting with a letter: "harbour-almanac". It is also the folder's name. ### PLG-MANIFEST-NAME {#plg-manifest-name} **A name is missing or too long.** Error; since 2026-10-01. Its message names the field or the line. What to do: Give each name per language, 80 characters or fewer: { "en": "Harbour Almanac", "es": "Almanaque del puerto" }. ### PLG-MANIFEST-VERSION {#plg-manifest-version} **The version is not major.minor.patch.** Error; since 2026-10-01. Its message names the field or the line. What to do: Use three numbers, such as 1.0.0, and raise it with every change you publish. ### PLG-MANIFEST-TIMEZONE {#plg-manifest-timezone} **Not a named time zone.** Error; since 2026-10-01. Its message names the field or the line. What to do: Use an IANA zone such as America/Santo_Domingo, never an offset like -04:00. It decides your plug-in's today. ### PLG-MANIFEST-AUDIENCE {#plg-manifest-audience} **Unknown audience.** Error; since 2026-10-01. Its message names the field or the line. What to do: Use public, private or local. ### PLG-MANIFEST-SCHEDULE {#plg-manifest-schedule} **The schedule is not a five-field cron.** Error; since 2026-10-01. Its message names the field or the line. What to do: Write minute, hour, day of month, month and day of week, read in your time zone: "30 9 \* \* 1-5" is 09:30 on weekdays. ### PLG-MANIFEST-BACKFILL {#plg-manifest-backfill} **backfill is not all or a span.** Error; since 2026-10-01. Its message names the field or the line. What to do: Use "all", or a span such as "5y" or "90d": how far back your first run fills. ### PLG-MANIFEST-SOURCES {#plg-manifest-sources} **A source is not declared correctly.** Error; since 2026-10-01. Its message names the field or the line. What to do: Each source needs a lowercase name, used once, and an https url; maxBytes is at most 20 MB. At most 16 sources. ### PLG-MANIFEST-SOURCE-URL {#plg-manifest-source-url} **A source is not an https URL.** Error; since 2026-10-01. Its message names the field or the line. What to do: Kanboombo downloads only https URLs with a lowercase host. {today}, {year} and {month} are filled in your time zone. ### PLG-MANIFEST-SERIES {#plg-manifest-series} **A series is not declared correctly.** Error; since 2026-10-01. Its message names the field or the line. What to do: Each series needs a key, a kind (value, event, item), a cadence, its dates (observation or publication) and a name. Only value series have fields. ### PLG-MANIFEST-LINKS {#plg-manifest-links} **links.hosts is not a list of host names.** Error; since 2026-10-01. Its message names the field or the line. What to do: List the lowercase hosts your items may link to besides your sources' own, such as "www.bancentral.gov.do". At most 16. ### PLG-MANIFEST-LIMITS {#plg-manifest-limits} **A limit is out of range.** Error; since 2026-10-01. Its message names the field or the line. What to do: limits may only lower the defaults: seconds 1 to 120, megabytes 16 to 256. ### PLG-MANIFEST-LICENCE {#plg-manifest-licence} **The licence block is incomplete.** Error; since 2026-10-01. Its message names the field or the line. What to do: Name the holder, the attribution shown beside your records (40 characters or fewer), the terms URL (or null) and whether the data may be kept: yes, no or unknown. ### PLG-ID-RESERVED {#plg-id-reserved} **That id is reserved.** Error; since 2026-10-01. Its message names the field or the line. What to do: Choose another id: words the store's own addresses use (store, publish, days…), and ids starting kanboombo, kb-, chk- or test-, are taken. ### PLG-LISTING-INVALID {#plg-listing-invalid} **listing.json is not a listing.** Error; since 2026-10-01. Its message names the field or the line. What to do: Start from the listing in Reference › Listing: "listing": 1, then the keys it names and nothing else. ### PLG-LISTING-ID {#plg-listing-id} **The listing is for another plug-in.** Error; since 2026-10-01. Its message names the field or the line. What to do: Set "plugin" to your manifest's id. ### PLG-LISTING-PUBLISHER {#plg-listing-publisher} **No publisher domain.** Error; since 2026-10-01. Its message names the field or the line. What to do: Name the domain your organization verifies, such as { "domain": "bancodata.do" }. The store shows it beside your name. ### PLG-LISTING-TAGLINE {#plg-listing-tagline} **The tagline is missing or too long.** Error; since 2026-10-01. Its message names the field or the line. What to do: One line, 80 characters or fewer per language, that says what lands on your readers' days. ### PLG-LISTING-DESCRIPTION {#plg-listing-description} **The description is missing or too long.** Error; since 2026-10-01. Its message names the field or the line. What to do: Plain text, 2,000 characters or fewer per language, paragraphs separated by a blank line. Your readers are short of time. ### PLG-LISTING-LANGUAGE-MISSING {#plg-listing-language-missing} **No English.** Error; since 2026-10-01. Its message names the field or the line. What to do: Write the tagline and description in English (en) too; add your other languages beside it. ### PLG-LISTING-CATEGORY {#plg-listing-category} **Unknown category.** Error; since 2026-10-01. Its message names the field or the line. What to do: Pick the one category readers would look in first. ### PLG-LISTING-KEYWORDS {#plg-listing-keywords} **Too many keywords.** Error; since 2026-10-01. Its message names the field or the line. What to do: At most 12 words or short phrases a reader might search for, 30 characters each. ### PLG-LISTING-LINK {#plg-listing-link} **A link is not https.** Error; since 2026-10-01. Its message names the field or the line. What to do: Every link in a listing is https. ### PLG-NO-PRIVACY-POLICY {#plg-no-privacy-policy} **No privacy policy.** Error; since 2026-10-01. Its message names the field or the line. What to do: Link a page that says who you are and what your plug-in collects: with contract 1, nothing about your readers. ### PLG-NO-SUPPORT-CONTACT {#plg-no-support-contact} **No way to reach you.** Error; since 2026-10-01. Its message names the field or the line. What to do: Give links.support a url, an email, or both. ### PLG-LISTING-SOURCE {#plg-listing-source} **A listed source is wrong.** Error; since 2026-10-01. Its message names the field or the line. What to do: Describe only sources your manifest declares: a title, the page it comes from, and its licence. ### PLG-LISTING-EXAMPLE {#plg-listing-example} **An example day is wrong.** Error; since 2026-10-01. Its message names the field or the line. What to do: Name 1 to 3 days your fixtures cover. The store draws each one from your plug-in's own output, so pick days that show what it does. ### PLG-LISTING-ICON {#plg-listing-icon} **The icon is not right.** Error; since 2026-10-01. Its message names the field or the line. What to do: A 256×256 PNG, 64 KB or less, that still reads at 14 pixels. ## A run's records {#stage-records} Found in what fetch.mjs prints. The Server refuses the whole batch on any of these; nothing from the run is written. ### PLG-RECORD-TODAY {#plg-record-today} **The run had no today.** Error; since 2026-10-01. Its message names the field or the line. What to do: This is Kanboombo's side, not yours: the run context lacked a valid today. Run the checks again. ### PLG-RECORD-TOO-MANY {#plg-record-too-many} **Too many records in one run.** Error; since 2026-10-01. Its message names the field or the line. What to do: Print at most 250,000 records per run. A larger history fills over several runs: set backfill to a span. ### PLG-RECORD-INVALID {#plg-record-invalid} **A record breaks contract 1.** Error; since 2026-10-01. Its message names the field or the line. What to do: The sentence names the line. Compare it with Reference › Records: every value is a finite number, every date YYYY-MM-DD. ### PLG-RECORD-SERIES {#plg-record-series} **A record for a series the manifest does not declare.** Error; since 2026-10-01. Its message names the field or the line. What to do: Print only the series plugin.json declares, each with its declared kind. ### PLG-RECORD-UNKNOWN-KEY {#plg-record-unknown-key} **A record has a key contract 1 does not know.** Error; since 2026-10-01. Its message names the field or the line. What to do: Each kind has its own keys (Reference › Records). Remove the extra one. ### PLG-RECORD-AFTER-TODAY {#plg-record-after-today} **A record dated after today.** Error; since 2026-10-01. Its message names the field or the line. What to do: Usually a time-zone mistake: date your records in the manifest's zone, never with toISOString(). Only a scheduled event may be in the future. ### PLG-RECORD-LINK {#plg-record-link} **An item links somewhere it may not.** Error; since 2026-10-01. Its message names the field or the line. What to do: Item links are https, to a source's host or one listed in links.hosts. ### PLG-RECORD-REPEATED {#plg-record-repeated} **The same record twice in one run.** Error; since 2026-10-01. Its message names the field or the line. What to do: A record's key is its series, date and period (an event's or item's id). Print each once. ## Your folder {#stage-check} Found by `check` (kanboombo-plugin.mjs or npm run plugin) on your machine, and again when you upload. ### PLG-FOLDER-ID {#plg-folder-id} **The folder is not named after the id.** Error; since 2026-10-01. It says: `The folder is named {folder}, but the manifest's id is {id}` What to do: Rename the folder, or the id: they must match. ### PLG-FETCH-MISSING {#plg-fetch-missing} **No fetch.mjs.** Error; since 2026-10-01. It says: `There is no fetch.mjs in the folder` What to do: The fetcher is always fetch.mjs at the top of the folder. kanboombo-plugin.mjs init writes one. ### PLG-IMPORT-OUTSIDE {#plg-import-outside} **An import from outside the plug-in.** Error; since 2026-10-01. It says: `{file} imports {spec}, outside the plug-in's folder` What to do: A plug-in is self-contained: copy what you need into the folder. ### PLG-IMPORT-PACKAGE {#plg-import-package} **An import of an undeclared package.** Error; since 2026-10-01. It says: `{file} imports {spec}, which package.json does not declare` What to do: Import a Node builtin with its node: prefix (node:fs, never fs), or copy the code into the folder as a readable .mjs file. ### PLG-FIXTURE-SOURCE-MISSING {#plg-fixture-source-missing} **A source has no fixture.** Error; since 2026-10-01. It says: `There is no fixtures/sources/{source} for the source "{source}"` What to do: Save a real copy of each declared source in fixtures/sources/, named after it. Checks run on these, never on the network. ### PLG-FIXTURE-CONTEXT-INVALID {#plg-fixture-context-invalid} **fixtures/context.json is not valid.** Error; since 2026-10-01. Its message names the field or the line. What to do: Write { "now": "2026-10-01T13:30:00Z", "today": "2026-10-01" }, where today is now's date in your time zone. ### PLG-FIXTURE-RECORDS-MISSING {#plg-fixture-records-missing} **No fixtures/records.jsonl.** Error; since 2026-10-01. It says: `There is no fixtures/records.jsonl to compare with` What to do: Run check with --update once to write it, read it, and keep it. ### PLG-FIXTURE-MISMATCH {#plg-fixture-mismatch} **The output differs from fixtures/records.jsonl.** Error; since 2026-10-01. Its message names the field or the line. What to do: If the change is intended, run check with --update and read the difference before you keep it. ### PLG-RUN-FAILED {#plg-run-failed} **The fetcher exited with an error.** Error; since 2026-10-01. It says: `fetch.mjs exited {exit}: {reason}` What to do: The reason is the last line it wrote to stderr. Exit non-zero only when a source is not what you expect. ### PLG-RUN-KILLED {#plg-run-killed} **The fetcher was stopped at its limits.** Error; since 2026-10-01. It says: `fetch.mjs was stopped ({signal}) at its limits` What to do: It ran past its seconds or memory. Read the source once, stream large files, and print as you go. ### PLG-RUN-NETWORK {#plg-run-network} **The fetcher tried the network.** Error; since 2026-10-01. It says: `{reason}` What to do: Plug-ins have no network. Declare the file as a source in plugin.json and read it from the path the context gives. ### PLG-RUN-EXPECTED-FAILURE {#plg-run-expected-failure} **A run that should fail did not fail as expected.** Error; since 2026-10-01. It says: `fixtures/runs/{run} expects {expected}, but the fetcher {actual}` What to do: A broken source must stop the run, so nothing wrong is written: exit with expect.json's exit, and write a last stderr line that contains its reason. If it stopped and only the words differ, make the reason and that line agree. ### PLG-RUN-READ-OUTSIDE {#plg-run-read-outside} **The fetcher read a file outside its folder.** Error; since 2026-10-01. It says: `fetch.mjs tried to read {path}, outside its folder and its sources` What to do: Read only your own folder and the paths the run context gives in sources. On the Server nothing else is there to read. ## The upload {#stage-upload} Found when the portal reads your ZIP, before anything of it runs. ### PLG-ID-TAKEN {#plg-id-taken} **Another publisher has that id.** Error; since 2026-10-01. Its message names the field or the line. What to do: Choose another id. An id belongs to the organization that first used it, and is never reused. ### PLG-ID-RETIRED {#plg-id-retired} **That id was retired.** Error; since 2026-10-01. Its message names the field or the line. What to do: A retired id stays retired: readers' records are kept under it. Choose a new id. ### PLG-STORE-AUDIENCE {#plg-store-audience} **Store plug-ins are public.** Error; since 2026-10-01. Its message names the field or the line. What to do: Set "audience": "public". A private plug-in is set up for its readers at host.kanboombo.com, not published in the store. ### PLG-LICENCE-TERMS-MISSING {#plg-licence-terms-missing} **No terms for the data.** Error; since 2026-10-01. Its message names the field or the line. What to do: Link the terms the data is published under in licence.terms. The store shows them, and review reads them. ### PLG-SCHEDULE-TOO-FREQUENT {#plg-schedule-too-frequent} **It would run too often.** Error; since 2026-10-01. Its message names the field or the line. What to do: A plug-in may run at most 24 times a day. Most sources change once or twice a day: match yours. ### PLG-QUOTA-UPLOADS {#plg-quota-uploads} **Too many uploads today.** Error; since 2026-10-01. Its message names the field or the line. What to do: An organization uploads at most 20 versions a day. Check locally with kanboombo-plugin.mjs check first; it runs the same checks. ### PLG-ZIP-TOO-LARGE {#plg-zip-too-large} **The archive is too large.** Error; since 2026-10-01. It says: `The archive is {size}; an upload may be at most {limit}` What to do: Leave out what the plug-in does not need: trim large fixtures to the rows your checks read, and never include node_modules/. ### PLG-ZIP-NOT-ZIP {#plg-zip-not-zip} **Not a ZIP archive.** Error; since 2026-10-01. Its message names the field or the line. What to do: Upload the .zip that kanboombo-plugin.mjs pack writes, or compress the plug-in's folder. If the upload was cut short, try again. ### PLG-ZIP-PREPENDED {#plg-zip-prepended} **Something before the archive.** Error; since 2026-10-01. It says: `Something comes before the archive's first file, as in a self-extracting archive or a file with a ZIP glued on` What to do: Upload a plain .zip: compress the plug-in's folder, or use kanboombo-plugin.mjs pack. ### PLG-ZIP-MULTI-DISK {#plg-zip-multi-disk} **A split archive.** Error; since 2026-10-01. Its message names the field or the line. What to do: Make one .zip file, not a split or spanned archive. A plug-in is small enough for one. ### PLG-ZIP-ZIP64 {#plg-zip-zip64} **A ZIP64 archive.** Error; since 2026-10-01. Its message names the field or the line. What to do: A plug-in is small enough for a plain ZIP. Compress the folder with your system's own tool, or with kanboombo-plugin.mjs pack, rather than streaming it through zip. ### PLG-ZIP-ENCRYPTED {#plg-zip-encrypted} **An encrypted file.** Error; since 2026-10-01. It says: `{name} is encrypted` What to do: Upload the archive without a password. Nothing in a plug-in is secret: a person reads every file before it is published. ### PLG-ZIP-METHOD {#plg-zip-method} **A compression Kanboombo does not read.** Error; since 2026-10-01. It says: `{name} is compressed with method {method}; Kanboombo reads deflate (8) and stored (0) only` What to do: Compress with deflate, what every common zip tool uses by default; turn off bzip2, LZMA or deflate64. ### PLG-ZIP-TOO-MANY-ENTRIES {#plg-zip-too-many-entries} **Too many files.** Error; since 2026-10-01. It says: `The archive has {count} entries; the most is {limit}` What to do: Keep a few fixtures that show each case rather than every day's file. ### PLG-ZIP-UNCOMPRESSED-TOO-LARGE {#plg-zip-uncompressed-too-large} **The archive unpacks too large.** Error; since 2026-10-01. It says: `The archive unpacks to {size}; the most is {limit}` What to do: Trim your fixtures to the rows your checks read. The Server downloads the real sources; the archive only carries samples. ### PLG-ZIP-RATIO {#plg-zip-ratio} **A file compresses suspiciously well.** Error; since 2026-10-01. It says: `{name} unpacks to {ratio} times its compressed size; the most is {limit} times` What to do: That is how a ZIP bomb looks. If the file is real, shorten it: very repetitive sample files can keep only the rows your checks read. ### PLG-ZIP-DAMAGED {#plg-zip-damaged} **A damaged archive.** Error; since 2026-10-01. Its message names the field or the line. What to do: Compress the folder again and upload the new file. If it keeps happening, use kanboombo-plugin.mjs pack, which writes archives Kanboombo always reads. ### PLG-ZIP-PATH {#plg-zip-path} **A file name Kanboombo does not accept.** Error; since 2026-10-01. It says: `{name}: {detail}` What to do: Name files and folders with letters, digits, ".", "_", "-" and "$", each part starting with a letter, a digit or "$": no "..", no leading "/", no "\\", at most 200 characters and 8 parts. ### PLG-ZIP-DUPLICATE {#plg-zip-duplicate} **One name used twice.** Error; since 2026-10-01. It says: `{name} clashes with {other}: the same name twice, names that differ only in case, or a file where a folder is` What to do: Keep one of them. Names that differ only in case are one file on a Mac or a Windows computer, so they can never both be what you tested. ### PLG-ZIP-SYMLINK {#plg-zip-symlink} **A symbolic link.** Error; since 2026-10-01. It says: `{name} is a symbolic link` What to do: Put the file itself in the folder. What runs must be what was reviewed, and a link can point anywhere. ### PLG-ZIP-NOT-A-FILE {#plg-zip-not-a-file} **Something that is not a plain file.** Error; since 2026-10-01. It says: `{name} is {kind}` What to do: A plug-in holds plain files and folders only. Compress the folder again from a normal copy of it. ### PLG-ZIP-NO-MANIFEST {#plg-zip-no-manifest} **No plugin.json.** Error; since 2026-10-01. It says: `The archive has no plugin.json at its top, or in one folder that holds everything else` What to do: Compress the plug-in's folder itself, so plugin.json is at the top of the archive or inside a single folder named after your id. ### PLG-ZIP-DROPPED {#plg-zip-dropped} **Files your computer adds were left out.** Note; since 2026-10-01. It says: `Left out what your computer adds: {names}` What to do: Nothing to do: __MACOSX/ and .DS_Store are never part of a plug-in. ### PLG-ZIP-CODE-TOO-LARGE {#plg-zip-code-too-large} **The code is too large.** Error; since 2026-10-01. It says: `Your code is {size}; the most is {limit}` What to do: Code is everything outside fixtures/, listing/, listing.json, review.json and README files. Keep fetchers small and readable, and move sample data into fixtures/. ### PLG-FILE-HIDDEN {#plg-file-hidden} **A hidden file.** Error; since 2026-10-01. It says: `{name} is hidden: its name starts with a dot` What to do: Leave out hidden files such as .env, .git/ or .npmrc. Nothing secret belongs in a plug-in, and a reviewer must see every file. ### PLG-FILE-TYPE {#plg-file-type} **A kind of file the code may not hold.** Error; since 2026-10-01. It says: `{name} is not a .mjs, .json, .md or .txt file or a LICENSE` What to do: Your code is readable ES modules (.mjs), JSON, text and a licence. Sample data goes in fixtures/, the icon in listing/. ### PLG-DEPENDENCIES {#plg-dependencies} **Packages in a store plug-in.** Error; since 2026-10-01. Its message names the field or the line. What to do: Copy what you need into the folder as readable .mjs files (plugins/dop-economy/xlsx.mjs reads XLSX that way), remove node_modules/, and declare no dependencies. ### PLG-VERSION-NOT-GREATER {#plg-version-not-greater} **Not newer than what is published.** Error; since 2026-10-01. It says: `Version {version} is not greater than {published}, which is already published` What to do: Raise the version in plugin.json above every published one, then upload again. ## The locked room {#stage-room} Found when the Server runs your fixtures, twice, and each fixtures/runs case, in the locked room. ### PLG-CHECK-INFRA {#plg-check-infra} **The Server could not finish the checks.** Error; since 2026-10-01. Its message names the field or the line. What to do: Kanboombo's side, not yours: run the checks again. If it keeps happening, write to support. ### PLG-ROOM-RUN-FAILED {#plg-room-run-failed} **It failed in the locked room.** Error; since 2026-10-01. Its message names the field or the line. What to do: Run kanboombo-plugin.mjs check on the same fixtures. The room has no network, Node 24, and only your folder and its sources. ### PLG-ROOM-MISMATCH {#plg-room-mismatch} **In the locked room it printed something else.** Error; since 2026-10-01. Its message names the field or the line. What to do: Your fetcher depends on something the room does not have: the time, an environment variable, a file outside the folder. Print only what the sources say. ### PLG-ROOM-NONDETERMINISTIC {#plg-room-nondeterministic} **Two runs printed different records.** Error; since 2026-10-01. Its message names the field or the line. What to do: The same sources must print the same records: no clock, no randomness. Snapshot windows compare runs, so a changing output would revise records every run. ### PLG-ROOM-EXPECTED-FAILURE {#plg-room-expected-failure} **A broken source did not stop the run.** Error; since 2026-10-01. Its message names the field or the line. What to do: A run on a broken source must exit non-zero with a reason, so nothing wrong is written. ### PLG-EXAMPLE-MISMATCH {#plg-example-mismatch} **An example day is not what the locked room printed.** Error; since 2026-10-01. Its message names the field or the line. What to do: Your review information says what a reader sees on each example day; the room ran your fixtures and printed something else. Correct the example, or the fetcher, so the two agree. ## The live sources {#stage-probe} Found when the Server downloads your real sources once, and asks each page your listing links to. ### PLG-SOURCE-UNREACHABLE {#plg-source-unreachable} **A source did not answer.** Error; since 2026-10-01. Its message names the field or the line. What to do: Check the URL from outside your network; then run the checks again. A source that needs a login cannot be a source: contract 1 has no secrets. ### PLG-PROBE-RUN-FAILED {#plg-probe-run-failed} **It failed on today's real sources.** Error; since 2026-10-01. Its message names the field or the line. What to do: Your fixtures pass, but the real file is different now. Download it, add it to fixtures, and read it the way it is. ### PLG-PROBE-EXAMPLE-DIFFERS {#plg-probe-example-differs} **Today's source says something else for an example day.** Warning; since 2026-10-01. Its message names the field or the line. What to do: Sources revise. If the new figure is right, update your example days and fixtures. ### PLG-LINK-UNREACHABLE {#plg-link-unreachable} **A page you link to did not answer.** Warning; since 2026-10-01. Its message names the field or the line. What to do: Readers and reviewers follow these links: your privacy policy, support page, homepage, and each source's page and terms. Make sure each answers with a page over https. ## Submitting {#stage-submit} Found when you submit for review. ### PLG-REVIEW-EXAMPLES {#plg-review-examples} **Five example days are needed.** Error; since 2026-10-01. Its message names the field or the line. What to do: Give five days your plug-in puts something on, each with its series and the values a reader sees there. The checks run your plug-in and compare. ### PLG-REVIEW-NEGATIVES {#plg-review-negatives} **Three cases where nothing should appear are needed.** Error; since 2026-10-01. Its message names the field or the line. What to do: Give three: a day with no record (a holiday), a day showing the value in force (a weekend), and at least one broken source from fixtures/runs/\/ (with an expect.json) that must stop the run. ### PLG-REVIEW-SOURCE-TERMS {#plg-review-source-terms} **A source's terms are missing.** Error; since 2026-10-01. Its message names the field or the line. What to do: For each source, name its holder, link its terms, and say on what basis you publish it: an open licence, permission (with where it was given), the public domain or your own data. ### PLG-REVIEW-RELEASE-NOTES {#plg-review-release-notes} **Release notes are needed for an update.** Error; since 2026-10-01. Its message names the field or the line. What to do: Write a sentence or two, said to your readers, on what this version adds or fixes: 500 characters or fewer per language. ### PLG-REVIEW-INFO {#plg-review-info} **The review information is not acceptable.** Error; since 2026-10-01. Its message names the field or the line. What to do: Notes for the reviewer are plain text of 2,000 characters or fewer, and a video link is https. ### PLG-ATTESTATIONS-MISSING {#plg-attestations-missing} **Not every statement is confirmed.** Error; since 2026-10-01. It says: `Confirm: {missing}` What to do: Read each statement and tick it. A version goes to review only when all of them hold. ### PLG-AGREEMENT-NOT-ACCEPTED {#plg-agreement-not-accepted} **The Developer Agreement is not accepted.** Error; since 2026-10-01. Its message names the field or the line. What to do: An owner or admin accepts it at kanboombo://publish › Publisher. A new version is accepted again before your next submission. ### PLG-PUBLISHER-NOT-VERIFIED {#plg-publisher-not-verified} **Your domain is not verified.** Error; since 2026-10-01. It says: `Your organization's domain is {status}` What to do: Verify your domain at kanboombo://publish › Publisher, with a DNS record or a file on your site. Until then you can upload and run checks, but not submit or publish. ### PLG-INTAKE-NOT-INVITED {#plg-intake-not-invited} **Submissions are by invitation.** Error; since 2026-10-01. It says: `Kanboombo is taking plug-ins from invited organizations first, and yours is not invited yet` What to do: Ask Kanboombo for an invitation. Meanwhile you can upload, run checks and prepare your review information. ## Your organization {#stage-publisher} Found when your organization verifies its domain in the portal. ### PLG-PUBLISHER-DOMAIN-INVALID {#plg-publisher-domain-invalid} **Not a domain name.** Error; since 2026-10-01. Its message names the field or the line. What to do: Type the domain your organization's site is on, such as bancodata.do: no https://, no path, no port. ### PLG-PUBLISHER-DOMAIN-IP {#plg-publisher-domain-ip} **An IP address.** Error; since 2026-10-01. It says: `{domain} is an IP address, not a domain name` What to do: Verify a domain name. Readers recognise a name; an address proves nothing about who you are. ### PLG-PUBLISHER-DOMAIN-SINGLE-LABEL {#plg-publisher-domain-single-label} **A name with no dot.** Error; since 2026-10-01. It says: `{domain} is not a public domain name` What to do: Use the full domain, such as bancodata.do. ### PLG-PUBLISHER-DOMAIN-SHARED {#plg-publisher-domain-shared} **A shared host.** Error; since 2026-10-01. It says: `{domain} is on {suffix}, where anyone can have a site` What to do: Verify a domain your organization owns. A site on a shared host proves only that you have an account there. ### PLG-PUBLISHER-DOMAIN-TAKEN {#plg-publisher-domain-taken} **Another organization verified this domain.** Error; since 2026-10-01. It says: `{domain} is verified by another organization` What to do: If the domain is yours, ask whoever verified it to release it, or write to Kanboombo. One domain publishes for one organization. ### PLG-PUBLISHER-DNS-NO-RECORD {#plg-publisher-dns-no-record} **No verification record yet.** Error; since 2026-10-01. Its message names the field or the line. What to do: Add the TXT record exactly as shown. DNS can take an hour to reach everyone; then check again. ### PLG-PUBLISHER-DNS-TIMEOUT {#plg-publisher-dns-timeout} **DNS did not answer.** Error; since 2026-10-01. It says: `{name} did not answer within {seconds} seconds` What to do: Check again in a few minutes. If it keeps happening, ask whoever runs your DNS. ### PLG-PUBLISHER-DNS-FAILED {#plg-publisher-dns-failed} **DNS refused the question.** Error; since 2026-10-01. Its message names the field or the line. What to do: Check that the domain exists and its DNS answers, then check again. ### PLG-PUBLISHER-FILE-UNREACHABLE {#plg-publisher-file-unreachable} **The verification file could not be read.** Error; since 2026-10-01. Its message names the field or the line. What to do: Put the file at https://\/.well-known/kanboombo-verify.txt, served over https with a valid certificate and no sign-in. ### PLG-PUBLISHER-FILE-MISMATCH {#plg-publisher-file-mismatch} **The verification file does not hold your token.** Error; since 2026-10-01. Its message names the field or the line. What to do: The file holds one line: kanboombo-verify= and your token, exactly as shown. ## In the store {#stage-publish} Found when you unpublish a plug-in, list it again or retire it in the portal. ### PLG-LISTING-NOT-PUBLISHED {#plg-listing-not-published} **Not in the store yet.** Error; since 2026-10-07. It says: `{plugin} has no version published through the portal` What to do: Publish a version first. Unpublish, List again and Retire act on a plug-in that is in the store. ### PLG-LISTING-HELD {#plg-listing-held} **Kanboombo has it off the shelf.** Error; since 2026-10-07. It says: `Kanboombo took {plugin} off the store's shelf itself, so it is not yours to unpublish or list again` What to do: Ask Kanboombo, who unlisted it, to list it again. You can still retire it. ### PLG-LISTING-TAKEN-DOWN {#plg-listing-taken-down} **Kanboombo took it down.** Error; since 2026-10-07. It says: `Kanboombo took {plugin} down, so only Kanboombo can bring it back` What to do: Read the message Kanboombo sent your owners and admins, fix what it names and answer it. Kanboombo restores the same code, or a new version goes through review. ### PLG-LISTING-RETIRED {#plg-listing-retired} **It is retired.** Error; since 2026-10-07. It says: `{plugin} is retired: its runs stopped for good and its id is never used again` What to do: Nothing brings a retired plug-in back. Publish what replaces it under a new id. ### PLG-RETIRE-CONFIRM {#plg-retire-confirm} **Type the id to retire it.** Error; since 2026-10-07. It says: `To retire it, type its id, {plugin}, exactly` What to do: Retiring stops its runs for good and its id is never used again, so you type the id to say you mean it: lowercase, with its dashes.