DEV Community

Alex Chen
Alex Chen

Posted on

My arXiv Bot Ran 14 Nights on a Free Server. 2 Nights It Gave Up.

At 7 AM my phone lit up with a commit. The message said Ran. The file said # Digest 11. And nothing else.

That was night 12 of a small experiment: a bot that reads arXiv while I sleep, summarizes the newest papers in plain English, and appends a dated digest to a repo I open with coffee. No paid cloud. No dashboard. No one to page. One Python file, a free model tier, and a free server running cron.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The setup

I am a CS student in Halifax, and my reading list is a time traveler's joke. A paper I save on Monday is already old news by Wednesday. So the goal was deliberately small: at 2 AM UTC, fetch ten new cs.AI and cs.LG papers from the arXiv API, summarize each abstract in three plain sentences, and leave the result in a Git repo.

One rule mattered more than the code. If the run failed, the failure had to stay in the log. It was not allowed to dress up as a quiet success.

That rule is the whole article, in hindsight.

The code

Fetching needs no LLM at all. arXiv exposes a plain XML API, and Python's standard library handles it:

import sys
import os
import requests
import urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path

ARCHIVE = Path("archive.md")
FEED = (
    "http://export.arxiv.org/api/query?"
    "search_query=cat:cs.AI+OR+cat:cs.LG&"
    "sortBy=submittedDate&sortOrder=descending&max_results=10"
)

def fetch_entries():
    with urllib.request.urlopen(FEED, timeout=30) as r:
        root = ET.fromstring(r.read())
    ns = {"a": "http://www.w3.org/2005/Atom"}
    out = []
    for e in root.findall("a:entry", ns):
        out.append({
            "id": e.find("a:id", ns).text.split("/abs/")[-1],
            "title": e.find("a:title", ns).text.strip().replace("\n", " "),
            "abstract": e.find("a:summary", ns).text.strip().replace("\n", " "),
        })
    return out
Enter fullscreen mode Exit fullscreen mode

The summarizer is where MonkeyCode enters the story. One endpoint, one API key, one prompt:

def summarize(entry):
    prompt = (
        "You read arXiv abstracts for a CS student. "
        "Give me three plain sentences: the problem, the method, "
        "one limitation. No marketing.\n\n"
        f"{entry['title']}\n{entry['abstract']}"
    )
    resp = requests.post(
        f"{os.environ['MONKEYCODE_BASE_URL']}/chat/completions",
        headers={"Authorization": f"Bearer {os.environ['MONKEYCODE_API_KEY']}"},
        json={
            "model": os.environ["MONKEYCODE_MODEL"],
            "messages": [{"role": "user", "content": prompt}],
        },
        timeout=120,
    )
    resp.raise_for_status()
    return extract_text(resp.json())
Enter fullscreen mode Exit fullscreen mode

The environment keeps the config out of the code:

export MONKEYCODE_BASE_URL=...   # from your project dashboard
export MONKEYCODE_API_KEY=...    # same place
export MONKEYCODE_MODEL=...      # the free tier model id
Enter fullscreen mode Exit fullscreen mode

Now the part that taught me the most. A free tier does not promise a fixed response shape. One morning the endpoint answered with a plain string. Another morning it answered with an array of content blocks. So the parser negotiates instead of assuming:

def extract_text(payload):
    msg = payload["choices"][0]["message"]
    if isinstance(msg, str):
        return msg
    content = msg.get("content")
    if isinstance(content, str):
        return content
    return " ".join(
        p.get("text", "") for p in content if isinstance(p, dict)
    )
Enter fullscreen mode Exit fullscreen mode

The archive logic is where the interesting failures live. Mark a paper as seen even if summarising fails, and you never retry it. Forget the dedup set, and the digest grows duplicates like a garden with no fence:

def main():
    entries = fetch_entries()
    seen = {line.split("|", 1)[0] for line in ARCHIVE.read_text().splitlines()}
    fresh = [e for e in entries if e["id"] not in seen]
    if not fresh:
        sys.exit("nothing new")

    results = []
    for e in fresh:
        try:
            results.append((e, summarize(e)))
        except Exception as exc:
            results.append((e, f"summary failed: {exc}"))

    if len(results) > 3 and len({s for _, s in results}) == 1:
        sys.exit("degenerate output: every summary is identical")

    lines = [f"# Digest {len(seen) + 1}", ""]
    for e, s in results:
        lines.append(f"## {e['title']} ({e['id']})")
        lines.append(s)
        lines.append("")
    ARCHIVE.open("a").write("\n".join(lines) + "\n")

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

The scheduler is just cron on the free server:

0 2 * * * cd ~/arxiv-digest && python3 run.py >> digest.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Check the server timezone before you trust that line. I did not. The server runs UTC, I live in Atlantic time, so my 2 AM digest landed at 11 PM my time. I spent one night debugging a delay that never existed.

The two bad nights

Over two weeks: 14 scheduled runs, 70 papers pulled, 68 summaries written.

Night 12 produced the empty commit from the opening. The first version of my script caught the arXiv timeout, wrote nothing but a header, and committed anyway. A red log would have told a story. An empty file told a lie.

Night 9 was stranger. All ten summaries began with "This paper proposes a novel approach." The model found one sentence and reused it like a jackhammer. It is exactly the failure mode this week's DEV threads keep circling: the reviewer itself was never reviewed. My fix is the degeneracy guard — more than three identical summaries and the script refuses to write. It is a smoke alarm, not a solution.

Lessons

Idempotency is the feature. The seen set matters more than the model. A bot that appends duplicates is worse than a bot that never runs.

Fail loudly or fail quietly — never fail politely. Committed empty files look successful at 2 AM.

Wrap the model behind one function. When the response schema drifted, I fixed extract_text and nothing else moved. That is the whole argument for a boundary.

Free tiers are honest about being free. Zero dollars buys zero SLAs. The two bad nights were not sabotage. They were the expected behaviour of a system with no retry budget and no human on call.

Who should skip this

Anyone whose digest feeds a decision. If a missed summary means a missed deadline, you need retries, alerts, and a person with a pager — not a cron job and a prayer.

Anyone processing hundreds of papers a day. A free token allowance is a sandbox, not a compute farm. MonkeyCode's free model tier (10 million tokens when I checked) and its free server option handled a job this small easily, but quotas change. Plan from the current docs, not from my two weeks.

If you want to try the full loop, the whole file is above. Honestly, rewrite it. Copying my parser habits is how you inherit my night 12.

I ran this on MonkeyCode's free model access and a free server, and the lesson I would keep is not about the model at all. Predict your own failure night before you start. That prediction is the actual deliverable.

Top comments (0)