Docs
Dashboard
Examples / Monitoring and draftsBeta

News to drafts

Follow a few blogs or news pages and, each run, turn what is new since the last run into drafts for a person to review: a newsletter (Markdown and HTML) and one short social post per item, each with its source link. Nothing is sent and nothing is posted.

When to use it: You want drafts of a newsletter and short posts from new articles, for a person to review: nothing is sent or posted.

Usesextract over the index pages (the posts they list), extract over the new posts
Needsa model on the API (no model key of your own)
Siteyour sources' index pages (default: the GitHub changelog); the runner uses a stand-in blog
Outputoutput/newsletter.md, output/newsletter.html, output/posts.json, output/result.json
LanguagesNode and Python

How it works: one call lists the posts on your index pages (relative links are resolved against the page that lists them), the ones not seen before are read in a second call (a summary with the most concrete detail, who should care, and a plain post of at most 220 characters), and the drafts are laid out by the code, so every item keeps its own link. The newsletter starts with a DRAFT note and every post has "draft": true.

Drafts, not automation: send the newsletter with your mailing tool to people who subscribed, and post what you have read and agree with, under your own name.

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.

/** * News to drafts: find what is new on the blogs or news pages you follow since the last run, read the new posts, and * write drafts for a person to review: a newsletter (Markdown and HTML) and one short social post per item, each with * its source link. Nothing is sent or posted. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; SOURCES comma-separated index pages; RUNS, INTERVAL_SECONDS) * * The posts already seen are kept in STATE_DIR. Writes output/newsletter.md, output/newsletter.html, output/posts.json * and output/result.json (the latest round's drafts). */import { createHash } from "node:crypto";import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";import { join } from "node:path";import { Boxline } from "@boxline/sdk";const out = process.env.OUTPUT_DIR ?? "output";const stateDir = process.env.STATE_DIR ?? join(out, "state");const sources = (process.env.SOURCES ?? "https://github.blog/changelog/").split(",").map((u) => u.trim()).filter(Boolean).slice(0, 10);const runs = Number(process.env.RUNS ?? 1);const interval = Number(process.env.INTERVAL_SECONDS ?? 3600);const maxItems = Number(process.env.MAX_ITEMS ?? 8);const bx = new Boxline();type Item = { url: string; title: string; summary: string; takeaway: string; post: string };const seenFile = join(stateDir, `seen-${createHash("sha256").update(sources.join(",")).digest("hex").slice(0, 12)}.json`);const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);mkdirSync(stateDir, { recursive: true });mkdirSync(out, { recursive: true });const rounds: { round: number; found: number; fresh: string[] }[] = [];let latest: { items: Item[]; newsletter: string } = { items: [], newsletter: "" };let modelUsd = 0;for (let round = 1; round <= runs; round++) {  const seen = new Set<string>(existsSync(seenFile) ? JSON.parse(readFileSync(seenFile, "utf8")) : []);  // 1. The posts listed on each index page (titles and addresses), in one call.  const list = await bx.extract<{ posts: { title: string; url: string; date: string | null; source: string }[] }>({    urls: sources,    prompt: "The posts (articles, entries) these pages list, newest first: title, the post's link as written, date when shown, and source: the address of the page that lists it (as given).",    schema: { type: "object", properties: { posts: { type: "array", items: { type: "object", properties: { title: { type: "string" }, url: { type: "string" }, date: { type: ["string", "null"] }, source: { type: "string" } }, required: ["title", "url", "date", "source"] } } }, required: ["posts"] },  });  modelUsd += list.usage.costUsd;  // Links on index pages are often relative ("/blog/x"): resolved against the page that lists them.  const absolute = (p: { url: string; source: string }) => {    try {      return new URL(p.url, sources.includes(p.source) ? p.source : sources[0]).href;    } catch {      return "";    }  };  const fresh = list.data.posts.map((p) => ({ ...p, url: absolute(p) })).filter((p) => /^https?:\/\//.test(p.url) && !seen.has(p.url)).slice(0, maxItems);  console.log(`Round ${round}: ${list.data.posts.length} posts listed, ${fresh.length} new`);  // 2. The new posts themselves: a summary, why it matters, and a short post in plain words.  let items: Item[] = [];  if (fresh.length) {    const r = await bx.extract<{ items: Item[] }>({      urls: fresh.map((p) => p.url),      prompt:        "For each page: title; summary, two plain sentences with its most concrete detail; takeaway, one sentence on who should care; " +        "post, a social post of at most 220 characters in plain words: no hype, no emoji, no hashtags, no link (it is added). url: the page's address as given.",      schema: { type: "object", properties: { items: { type: "array", items: { type: "object", properties: { url: { type: "string" }, title: { type: "string" }, summary: { type: "string" }, takeaway: { type: "string" }, post: { type: "string" } }, required: ["url", "title", "summary", "takeaway", "post"] } } }, required: ["items"] },    });    modelUsd += r.usage.costUsd;    items = r.data.items.map((it, i) => ({ ...it, url: fresh.find((p) => p.url === it.url)?.url ?? fresh[i]?.url ?? it.url, post: it.post.slice(0, 220).trim() }));  }  for (const p of fresh) seen.add(p.url);  writeFileSync(seenFile, JSON.stringify([...seen]));  rounds.push({ round, found: list.data.posts.length, fresh: fresh.map((p) => p.url) });  // 3. The drafts. The newsletter is laid out here (not by the model), so every item keeps its own link.  if (items.length) {    const day = new Date().toISOString().slice(0, 10);    const md = [`<!-- DRAFT: review before sending -->`, `# What's new (${day})`, "", ...items.flatMap((it) => [`## [${it.title}](${it.url})`, "", it.summary, "", `*${it.takeaway}*`, ""])].join("\n");    const html = `<!doctype html><meta charset="utf-8"><title>What's new (${day})</title><!-- DRAFT: review before sending --><h1>What's new (${day})</h1>${items.map((it) => `<h2><a href="${esc(it.url)}">${esc(it.title)}</a></h2><p>${esc(it.summary)}</p><p><em>${esc(it.takeaway)}</em></p>`).join("")}`;    writeFileSync(join(out, "newsletter.md"), md);    writeFileSync(join(out, "newsletter.html"), html);    writeFileSync(join(out, "posts.json"), JSON.stringify(items.map((it) => ({ draft: true, text: `${it.post} ${it.url}`, source: it.url })), null, 2));    latest = { items, newsletter: md };    for (const it of items) console.log(`  draft post: ${it.post} ${it.url}`);  }  console.log(`Round ${round} done: ${items.length} new items drafted.`);  if (round === 1) console.log("Seen posts saved.");  if (round < runs) await new Promise((res) => setTimeout(res, interval * 1000));}writeFileSync(join(out, "result.json"), JSON.stringify({ sources, rounds, items: latest.items, usage: { modelUsd } }, null, 2));

Inputs

Environment variables the example reads:

  • SOURCES, MAX_ITEMS (default 8), STATE_DIR (keep it between runs), RUNS, INTERVAL_SECONDS.

Run it

Get the examples with git clone https://github.com/Boxline-dev/examples.git and open this one: cd examples/news-to-drafts. 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

Then 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.

Every post is a draft of at most 280 characters ending with its source link, the newsletter links every item and is marked as a draft. On the runner's stand-in blog: round 1 drafts the 3 posts there, round 2 only the 2 added since, and the drafts keep their own facts (Osaka, $0.017).

Source

github.com/Boxline-dev/examples/news-to-drafts 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.