Changelog watcher
A task reads the newest released entry of a product's changelog into JSON (version, date, title, the changes and a one-sentence summary) every morning, and the example says whether it is new since the last time it looked. A new entry is posted to your team's chat.
When to use it: You want to know when a product you rely on ships a new release.
| Uses | tasks.create with output and schedule, tasks.run and waitForRun (wait_for_run) |
|---|---|
| Needs | a plan with task schedules, and a model on the API |
| Site | the changelog to watch (CHANGELOG_URL, required). The runner uses its stand-in: an unreleased section first, then released entries |
| Output | output/latest.json and output/last-seen.json; a chat message when the entry is new |
| Languages | Node and Python |
The example runs the task once right away, then switches the schedule off unless KEEP_SCHEDULE=1. With the schedule on, compare in your task_run.finished webhook receiver (see "Watch a price").
The code
The example as it is in its folder. Both versions do the same job and write the same output/, so one result check covers both.
/** * Changelog watcher: a task reads the newest released entry of a product's changelog into JSON (version, date, title, * changes, a one-sentence summary) every morning, and the example says whether it is new since the last time it looked. * A new entry is posted to your team's chat (a Slack-style incoming webhook) when SLACK_WEBHOOK_URL is set. * * CHANGELOG_URL=https://… npx tsx node/index.ts (BOXLINE_API_KEY; SCHEDULE; KEEP_SCHEDULE=1 to leave it running; * SLACK_WEBHOOK_URL optional) * * The example runs the task once right away instead of waiting for the schedule, then switches the schedule off unless * KEEP_SCHEDULE=1. The last version seen is kept in output/last-seen.json. Writes output/latest.json. */import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline, type TaskCreateParams, type TaskRun } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const changelog = process.env.CHANGELOG_URL;if (!changelog) throw new Error("Set CHANGELOG_URL to the changelog or release notes page to watch");const cron = process.env.SCHEDULE ?? "0 8 * * *"; // every day at 08:00 UTCconst keep = process.env.KEEP_SCHEDULE === "1";const chatHook = process.env.SLACK_WEBHOOK_URL;const bx = new Boxline();mkdirSync(out, { recursive: true });interface Entry { version: string; date: string; title: string; changes: string[]; summary: string;}const name = `Changelog watcher: ${new URL(changelog).host}`.slice(0, 100);const spec: TaskCreateParams = { name, instruction: "Open %changelog% and find the newest RELEASED entry of the changelog (skip anything marked unreleased or upcoming). " + "Give its version, its release date as YYYY-MM-DD, its title, the list of changes exactly as written, and a " + "one-sentence summary of what is new.", variables: [{ name: "changelog", default: changelog, description: "The changelog page" }], output: { type: "object", properties: { version: { type: "string", minLength: 1 }, date: { type: "string", description: "YYYY-MM-DD" }, title: { type: "string" }, changes: { type: "array", items: { type: "string" }, minItems: 1 }, summary: { type: "string" }, }, required: ["version", "date", "title", "changes", "summary"], additionalProperties: false, }, maxSteps: 10, schedule: { cron, timezone: "UTC", enabled: true },};const existing = (await bx.tasks.list({ limit: 100 })).data.find((t) => t.name === name);const task = existing ? await bx.tasks.update(existing.id, spec) : await bx.tasks.create(spec);const schedule = { cron: task.schedule!.cron, nextRunAt: task.schedule!.nextRunAt, setAt: new Date().toISOString() };console.log(`Task ${task.id} "${name}": runs "${cron}" (UTC), next at ${schedule.nextRunAt}`);let run: TaskRun<Entry>;try { const started = await bx.tasks.run<Entry>(task.id); console.log(`Run ${started.id} (agent run ${started.runId}) · Session: ${started.sessionId}`); run = await bx.tasks.waitForRun<Entry>(started, { timeoutMs: 5 * 60_000 });} finally { if (!keep) await bx.tasks.update(task.id, { schedule: { enabled: false } });}if (run.status !== "completed" || !run.result) throw new Error(`the run ${run.status}: ${run.error}`);const entry = run.result;// New since last time? (A scheduled run would do this in your task_run.finished webhook receiver.)const stateFile = join(out, "last-seen.json");const last = existsSync(stateFile) ? (JSON.parse(readFileSync(stateFile, "utf8")) as { version: string }) : null;const isNew = !last || last.version !== entry.version;writeFileSync(stateFile, JSON.stringify({ version: entry.version, date: entry.date, seenAt: new Date().toISOString() }, null, 2));console.log(`${isNew ? "NEW" : "no change"}: ${entry.version} (${entry.date}) ${entry.title}: ${entry.summary}`);for (const c of entry.changes) console.log(` - ${c}`);// A new entry goes to the team's chat, written like a changelog post.let posted = false;if (isNew && chatHook) { const text = [`New release at ${changelog}: *${entry.version}* (${entry.date}) ${entry.title}`, "", entry.summary, "", ...entry.changes.map((c) => `• ${c}`)].join("\n"); const res = await fetch(chatHook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) }); posted = res.ok; console.log(res.ok ? "Posted the new entry to the chat webhook." : `The chat webhook answered ${res.status}.`);}writeFileSync(join(out, "latest.json"), JSON.stringify(entry, null, 2));writeFileSync( join(out, "result.json"), JSON.stringify({ changelog, task: { id: task.id, name, schedule, enabledAtEnd: keep }, run: { id: run.id, status: run.status }, entry, isNew, previous: last?.version ?? null, posted, agentRuns: [{ id: run.runId }], made: { tasks: [task.id] } }, null, 2),);"""Changelog watcher: a task reads the newest released entry of a product's changelog into JSON (version, date, title,changes, a one-sentence summary) every morning, and the example says whether it is new since the last time it looked.A new entry is posted to your team's chat (a Slack-style incoming webhook) when SLACK_WEBHOOK_URL is set. CHANGELOG_URL=https://… python python/main.py (BOXLINE_API_KEY; SCHEDULE; KEEP_SCHEDULE=1 to leave it running; SLACK_WEBHOOK_URL optional)The example runs the task once right away instead of waiting for the schedule, then switches the schedule off unlessKEEP_SCHEDULE=1. The last version seen is kept in output/last-seen.json. Writes output/latest.json."""import jsonimport osimport urllib.requestfrom datetime import datetime, timezonefrom pathlib import Pathfrom urllib.parse import urlparsefrom boxline import Boxlineout = Path(os.environ.get("OUTPUT_DIR", "output"))changelog = os.environ.get("CHANGELOG_URL")if not changelog: raise SystemExit("Set CHANGELOG_URL to the changelog or release notes page to watch")cron = os.environ.get("SCHEDULE", "0 8 * * *") # every day at 08:00 UTCkeep = os.environ.get("KEEP_SCHEDULE") == "1"chat_hook = os.environ.get("SLACK_WEBHOOK_URL")bx = Boxline()out.mkdir(parents=True, exist_ok=True)name = f"Changelog watcher: {urlparse(changelog).netloc}"[:100]spec = dict( instruction=( "Open %changelog% and find the newest RELEASED entry of the changelog (skip anything marked unreleased or upcoming). " "Give its version, its release date as YYYY-MM-DD, its title, the list of changes exactly as written, and a " "one-sentence summary of what is new." ), variables=[{"name": "changelog", "default": changelog, "description": "The changelog page"}], output={ "type": "object", "properties": { "version": {"type": "string", "minLength": 1}, "date": {"type": "string", "description": "YYYY-MM-DD"}, "title": {"type": "string"}, "changes": {"type": "array", "items": {"type": "string"}, "minItems": 1}, "summary": {"type": "string"}, }, "required": ["version", "date", "title", "changes", "summary"], "additionalProperties": False, }, max_steps=10, schedule={"cron": cron, "timezone": "UTC", "enabled": True},)existing = next((t for t in bx.tasks.list(limit=100).data if t["name"] == name), None)task = bx.tasks.update(existing["id"], name=name, **spec) if existing else bx.tasks.create(name, **spec)schedule = {"cron": task["schedule"]["cron"], "nextRunAt": task["schedule"]["nextRunAt"], "setAt": datetime.now(timezone.utc).isoformat()}print(f'Task {task["id"]} "{name}": runs "{cron}" (UTC), next at {schedule["nextRunAt"]}')try: started = bx.tasks.run(task["id"]) print(f"Run {started['id']} (agent run {started['runId']}) · Session: {started['sessionId']}", flush=True) run = bx.tasks.wait_for_run(started, timeout=300)finally: if not keep: bx.tasks.update(task["id"], schedule={"enabled": False})if run["status"] != "completed" or not run["result"]: raise SystemExit(f"the run {run['status']}: {run['error']}")entry = run["result"]# New since last time? (A scheduled run would do this in your task_run.finished webhook receiver.)state_file = out / "last-seen.json"last = json.loads(state_file.read_text()) if state_file.exists() else Noneis_new = not last or last["version"] != entry["version"]state_file.write_text(json.dumps({"version": entry["version"], "date": entry["date"], "seenAt": datetime.now(timezone.utc).isoformat()}, indent=2))print(f"{'NEW' if is_new else 'no change'}: {entry['version']} ({entry['date']}) {entry['title']}: {entry['summary']}")for c in entry["changes"]: print(f" - {c}")# A new entry goes to the team's chat, written like a changelog post.posted = Falseif is_new and chat_hook: text = "\n".join([f"New release at {changelog}: *{entry['version']}* ({entry['date']}) {entry['title']}", "", entry["summary"], "", *(f"• {c}" for c in entry["changes"])]) req = urllib.request.Request(chat_hook, data=json.dumps({"text": text}).encode(), headers={"content-type": "application/json"}, method="POST") try: with urllib.request.urlopen(req, timeout=30): posted = True print("Posted the new entry to the chat webhook.") except Exception as err: print(f"The chat webhook failed: {err}")(out / "latest.json").write_text(json.dumps(entry, indent=2, ensure_ascii=False))result = {"changelog": changelog, "task": {"id": task["id"], "name": name, "schedule": schedule, "enabledAtEnd": keep}, "run": {"id": run["id"], "status": run["status"]}, "entry": entry, "isNew": is_new, "previous": last["version"] if last else None, "posted": posted, "agentRuns": [{"id": run["runId"]}], "made": {"tasks": [task["id"]]}}(out / "result.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))Inputs
Environment variables the example reads:
CHANGELOG_URL: the changelog or release notes page (required).SCHEDULE: the cron (UTC) (default0 8 * * *).KEEP_SCHEDULE: leave the schedule on at the end (for example1).SLACK_WEBHOOK_URL: a Slack-style incoming webhook ({"text": …}) that gets new entries (optional).
Run it
Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/changelog-watcher. Put your API key in BOXLINE_API_KEY (and BOXLINE_API_URL for another API), then run either version from the example’s folder. Both write to output/ and stop their sessions when they finish.
# Node 18 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keynpm install @boxline/sdk tsxnpx tsx node/index.ts# Python 3.9 or newer, in the example's folderexport BOXLINE_API_KEY=… # your API keypip install boxline-sdkpython python/main.pyThen check the result, from the same folder: npx tsx check.ts output. It exits with 0 only when the result is right.
What its check proves
check.ts reads what the example wrote and passes only when the result is right, not merely when the example ran.
The schedule's next run is 08:00 UTC, and on the stand-in the entry is 2.4.0 (2026-09-24) with its 3 changes (the unreleased section skipped), reported as new on the first look and posted once to the stand-in's chat webhook with its version and every change.
Source
github.com/Boxline-dev/examples/changelog-watcher on GitHub, with its README.md, check.ts and .env.example. The examples are open source. See all examples.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.