Docs
Dashboard
Examples / Chat and researchBeta

Daily tech digest

Collect today's stories from Hacker News and a few blogs, keep the ones on your topics, rank them, read the top ones in a cloud browser and summarise them, then make a Markdown digest and a PDF slide deck and post the digest to your chat. Run it every morning from cron or a CI schedule.

When to use it: You want a morning digest of the stories on your topics, as Markdown, a PDF deck and a chat post.

Usesa shell session (setup, exec, files), extract over several pages
Needsshell sessions and a model on the API (no model key of your own); a Slack-style webhook if you want it in chat
SiteHacker News' official search API and your feeds; the runner uses a stand-in set of stories
Outputoutput/digest.md, output/deck.pdf (a title slide and one per story), output/result.json
LanguagesNode and Python

What runs where:

  1. On the machine: collect.py gathers the stories in the session's shell: Hacker News through its official search API (no page scraping), and each RSS or Atom feed. It writes /workspace/items.json.
  2. Here: the example reads that file, keeps the stories whose titles have one of your topic words (and none of the excluded ones) and that are fresh enough, and ranks them: points per hour and comments for Hacker News, a steady score for blog posts, plus each topic word the title matches.
  3. In a sandboxed browser: one extract call reads the top stories' pages and returns a summary (with the page's most concrete fact) and why it matters. A page that does not load, answers with an HTTP error, or is a PDF (not_a_web_page) is listed under "Could not be read" instead of being summarised.
  4. On the machine again: deck.py builds the slide deck with fpdf2, which the session's setup installed.

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.

/** * Daily tech digest: collect today's stories from Hacker News and a few blogs, keep the ones on your topics, rank * them, read the top ones in a sandboxed browser and summarise them, then make a Markdown digest and a PDF slide deck * and post the digest to your chat. * *   npx tsx node/index.ts            (BOXLINE_API_KEY; a plan with shell sessions; INCLUDE, FEEDS, TOP, SLACK_WEBHOOK_URL) * * The machine does the plumbing: collect.py gathers the stories in the session's shell (Hacker News' official search * API and the feeds), and deck.py builds the PDF there with fpdf2 (installed by the session's setup). One extract * call reads the top stories' pages. Writes output/result.json, output/digest.md and output/deck.pdf. */import { mkdirSync, readFileSync, writeFileSync } from "node:fs";import { dirname, join } from "node:path";import { fileURLToPath } from "node:url";import { Boxline } from "@boxline/sdk";const here = dirname(fileURLToPath(import.meta.url));const out = process.env.OUTPUT_DIR ?? "output";const hnUrl = process.env.HN_URL ?? "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=50";const feeds = process.env.FEEDS ?? "https://github.blog/feed/,https://aws.amazon.com/blogs/aws/feed/";const words = (v: string | undefined) => (v ?? "").split(",").map((w) => w.trim().toLowerCase()).filter(Boolean);const include = words(process.env.INCLUDE ?? "ai,llm,agent,agents,browser,security,database,open source");const exclude = words(process.env.EXCLUDE);const top = Math.min(Number(process.env.TOP ?? 5), 10); // one extract call reads at most 10 pagesconst maxAgeHours = Number(process.env.MAX_AGE_HOURS ?? 48);const chatHook = process.env.SLACK_WEBHOOK_URL;const bx = new Boxline();type Item = { source: "hn" | "feed"; feed?: string; title: string; url: string; discussion?: string; points: number; comments: number; at: number | null };const hits = (title: string, list: string[]) => list.filter((w) => new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(title));const session = await bx.sessions.create({  browser: false,  shell: true,  setup: ["pip install -q fpdf2"],  timeout: 900,  idleTimeout: 300,  userMetadata: { example: "daily-tech-digest" },});console.log(`Session: ${session.id}`);try {  // 1. Collect on the machine: the scripts go into /workspace, the sources' addresses in as environment variables.  await session.files.write("collect.py", readFileSync(join(here, "../collect.py")));  await session.files.write("deck.py", readFileSync(join(here, "../deck.py")));  const c = await session.exec("python collect.py", { env: { HN_URL: hnUrl, FEEDS: feeds }, timeoutMs: 120_000 });  if (c.exitCode !== 0) throw new Error(`collect.py failed: ${c.stderr.trim()}`);  console.log(c.stdout.trim());  const collected = JSON.parse(await session.files.readText("items.json")) as { collectedAt: number; items: Item[]; errors: string[] };  // 2. Keep the fresh ones on your topics, then rank: points per hour and comments for Hacker News, a steady score  //    for blogs (their posts are chosen by their editors), plus each topic word the title matches.  const now = collected.collectedAt;  const seen = new Set<string>();  const kept = collected.items    .filter((i) => !seen.has(i.url) && seen.add(i.url))    .filter((i) => (include.length ? hits(i.title, include).length > 0 : true) && hits(i.title, exclude).length === 0)    .filter((i) => i.at === null || (now - i.at) / 3600 <= maxAgeHours)    .map((i) => {      const hours = Math.max((now - (i.at ?? now)) / 3600, 1);      const base = i.source === "hn" ? 2 * Math.log10(1 + i.points / hours) + Math.log10(1 + i.comments) : 2.5 - Math.min(hours / 48, 1);      return { ...i, score: Math.round((base + 0.5 * hits(i.title, include).length) * 100) / 100 };    })    .sort((a, b) => b.score - a.score);  const chosen = kept.slice(0, top);  console.log(`${collected.items.length} collected, ${kept.length} on your topics; the top ${chosen.length}:`);  for (const i of chosen) console.log(`  ${i.score.toFixed(2)}  ${i.title}  (${i.source === "hn" ? `${i.points} points` : new URL(i.url).host})`);  if (!chosen.length) throw new Error("nothing matched your topics; widen INCLUDE or add FEEDS");  // 3. Read the chosen pages and summarise them in one call (a page that does not load is listed with an error).  const r = await bx.extract<{ stories: { url: string; summary: string; whyItMatters: string }[] }>({    urls: chosen.map((i) => i.url),    prompt: "For each page: summary, two sentences on what it says, with its most concrete fact or number; whyItMatters, one sentence for an engineer. url: the page's address as given.",    schema: {      type: "object",      properties: {        stories: {          type: "array",          items: { type: "object", properties: { url: { type: "string" }, summary: { type: "string" }, whyItMatters: { type: "string" } }, required: ["url", "summary", "whyItMatters"] },        },      },      required: ["stories"],    },  });  const key = (u: string) => u.replace(/[#?].*$/, "").replace(/\/$/, "");  const byUrl = new Map(r.data.stories.map((s) => [key(s.url), s]));  const all = chosen.map((i, n) => {    const page = r.pages[n];    // A page that did not load, or answered with an error (a 503 page is not the article), is not summarised.    const why = page?.error ? page.error.code : page && page.status !== null && page.status >= 400 ? `HTTP ${page.status}` : null;    const s = why ? undefined : (byUrl.get(key(i.url)) ?? byUrl.get(key(page?.finalUrl ?? "")));    return {      title: i.title,      url: i.url,      where: i.source === "hn" ? `Hacker News · ${i.points} points · ${i.comments} comments` : new URL(i.url).host,      read: Boolean(s),      summary: s?.summary ?? `The page could not be read (${why ?? "no summary"}).`,      whyItMatters: s?.whyItMatters ?? "",      score: i.score,    };  });  const stories = all.filter((x) => x.read);  const unread = all.filter((x) => !x.read);  if (!stories.length) throw new Error(`none of the top pages could be read: ${unread.map((x) => `${x.url} (${x.summary})`).join("; ")}`);  // 4. The digest: Markdown here, a PDF deck built in the shell, and a chat post.  const day = new Date(now * 1000).toISOString().slice(0, 10);  const digest = { title: `Tech digest, ${day}`, subtitle: `${stories.length} stories from ${collected.items.length} collected (${include.join(", ")})`, stories };  const md = [    `# ${digest.title}`,    "",    digest.subtitle,    "",    ...stories.flatMap((s, n) => [`## ${n + 1}. [${s.title}](${s.url})`, "", `*${s.where}*`, "", s.summary, "", `**Why it matters:** ${s.whyItMatters}`, ""]),    ...(unread.length ? ["## Could not be read", "", ...unread.map((s) => `- [${s.title}](${s.url}): ${s.summary}`), ""] : []),  ].join("\n");  await session.files.write("digest.json", JSON.stringify(digest));  const deck = await session.exec("python deck.py", { timeoutMs: 120_000 });  if (deck.exitCode !== 0) throw new Error(`deck.py failed: ${deck.stderr.trim()}`);  console.log(deck.stdout.trim());  mkdirSync(out, { recursive: true });  writeFileSync(join(out, "deck.pdf"), await session.files.read("deck.pdf"));  writeFileSync(join(out, "digest.md"), md);  let alertsSent = 0;  if (chatHook) {    const text = `*${digest.title}*\n\n${stories.map((s, n) => `${n + 1}. <${s.url}|${s.title}>\n${s.summary}`).join("\n\n")}`;    const res = await fetch(chatHook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) });    alertsSent = res.ok ? 1 : 0;    console.log(res.ok ? "Posted the digest to the chat webhook." : `The chat webhook answered ${res.status}.`);  }  writeFileSync(    join(out, "result.json"),    JSON.stringify({ collected: collected.items.length, sourceErrors: collected.errors, kept: kept.length, stories, unread, pages: r.pages, alertsSent, usage: { modelUsd: r.usage.costUsd } }, null, 2),  );  console.log(`\n${md}`);} finally {  await session.stop();}
collect.py
"""Runs in the session's shell: collects stories from Hacker News (its official search API) and RSS/Atom feeds into/workspace/items.json. Inputs come as environment variables: HN_URL and FEEDS (comma-separated)."""import jsonimport osimport timeimport urllib.requestimport xml.etree.ElementTree as ETfrom email.utils import parsedate_to_datetimefrom datetime import datetimeUA = {"user-agent": "daily-tech-digest (a Boxline example)"}def get(url):    with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:        return r.read()def when(text):    if not text:        return None    try:        return int(parsedate_to_datetime(text).timestamp())  # RSS: RFC 822 dates    except (TypeError, ValueError):        pass    try:        return int(datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp())  # Atom: ISO 8601    except ValueError:        return Noneitems, errors = [], []hn = os.environ.get("HN_URL")if hn:    try:        for h in json.loads(get(hn))["hits"]:            link = h.get("url") or f"https://news.ycombinator.com/item?id={h['objectID']}"            items.append({                "source": "hn",                "title": h["title"],                "url": link,                "discussion": f"https://news.ycombinator.com/item?id={h['objectID']}",                "points": h.get("points") or 0,                "comments": h.get("num_comments") or 0,                "at": h.get("created_at_i"),            })    except Exception as e:  # one source failing does not stop the others        errors.append(f"{hn}: {e}")for feed in [f.strip() for f in os.environ.get("FEEDS", "").split(",") if f.strip()]:    try:        root = ET.fromstring(get(feed))        atom = "{http://www.w3.org/2005/Atom}"        entries = root.findall("./channel/item") or root.findall(f"{atom}entry")        for e in entries[:20]:            if e.tag == "item":                title, link, at = e.findtext("title"), e.findtext("link"), when(e.findtext("pubDate"))            else:                title = e.findtext(f"{atom}title")                node = e.find(f"{atom}link[@rel='alternate']") or e.find(f"{atom}link")                link = node.get("href") if node is not None else None                at = when(e.findtext(f"{atom}published") or e.findtext(f"{atom}updated"))            if title and link:                items.append({"source": "feed", "feed": feed, "title": title.strip(), "url": link.strip(), "points": 0, "comments": 0, "at": at})    except Exception as e:        errors.append(f"{feed}: {e}")with open("/workspace/items.json", "w") as f:    json.dump({"collectedAt": int(time.time()), "items": items, "errors": errors}, f, indent=1)print(f"{len(items)} items, {len(errors)} sources failed")for e in errors:    print(f"  failed: {e}")
deck.py
"""Runs in the session's shell: turns /workspace/digest.json into a slide deck, /workspace/deck.pdf (a title slide,then one slide per story). Needs fpdf2 (the session's setup installs it)."""import jsonimport osfrom fpdf import FPDFd = json.load(open("/workspace/digest.json"))font = next((p for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf") if os.path.exists(p)), None)pdf = FPDF(orientation="landscape", format="A4")pdf.set_auto_page_break(False)pdf.set_margins(20, 15, 20)  # ln() goes back to x = 20if font:    pdf.add_font("body", "", font)    pdf.add_font("body", "B", font.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf") if os.path.exists(font.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")) else font)    family = "body"else:    family = "helvetica"def text(s):    return s if font else s.encode("latin-1", "replace").decode("latin-1")pdf.add_page()pdf.set_font(family, "B", 40)pdf.set_xy(20, 70)pdf.multi_cell(257, 18, text(d["title"]), align="L")pdf.set_font(family, "", 18)pdf.set_x(20)pdf.multi_cell(257, 10, text(d["subtitle"]), align="L")for i, s in enumerate(d["stories"], 1):    pdf.add_page()    pdf.set_fill_color(30, 64, 175)    pdf.rect(0, 0, 297, 8, "F")    pdf.set_xy(20, 20)    pdf.set_font(family, "", 12)    pdf.cell(0, 8, text(f"{i} / {len(d['stories'])}   ·   {s['where']}"))    pdf.set_xy(20, 32)    pdf.set_font(family, "B", 26)    pdf.multi_cell(257, 12, text(s["title"]), align="L")    pdf.set_x(20)    pdf.set_font(family, "", 16)    pdf.ln(4)    pdf.multi_cell(257, 9, text(s["summary"]), align="L")    pdf.ln(4)    pdf.set_x(20)    pdf.set_font(family, "B", 14)    pdf.multi_cell(257, 8, text(f"Why it matters: {s['whyItMatters']}"), align="L")    pdf.set_xy(20, 190)    pdf.set_font(family, "", 10)    pdf.set_text_color(30, 64, 175)    pdf.cell(0, 6, text(s["url"][:150]), link=s["url"])    pdf.set_text_color(0, 0, 0)pdf.output("/workspace/deck.pdf")print(f"deck.pdf: {len(d['stories']) + 1} slides, font {'DejaVu' if font else 'Helvetica (latin-1)'}")

Inputs

Environment variables the example reads:

  • INCLUDE, EXCLUDE: topic words, comma-separated (default ai, llm, agent, agents, browser, security, database, open source; none excluded).
  • TOP (default 5, at most 10) and MAX_AGE_HOURS (default 48).
  • HN_URL, FEEDS: the sources (default Hacker News' front page, the GitHub and AWS blogs).
  • SLACK_WEBHOOK_URL: your chat's incoming webhook (Slack's {"text": …} format).

Run it

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

Run from this folder (both versions upload collect.py and deck.py from it).

# 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 story has a real summary, the deck is a PDF with a title slide and one slide per story, and the digest exists. On the runner's stand-in stories: exactly the 4 that match the topics and are fresh are chosen (a popular story off-topic, an "Ask HN", a 100-hour-old story and an office move are left out), each summary holds the fact that is only on its page, and the digest reached the stand-in chat webhook.

Source

github.com/Boxline-dev/examples/daily-tech-digest 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.