Late August in Halifax smells like rain and new backpacks. For me, it also smells like guilt: forty-seven pages of machine learning notes from last spring, sitting in a folder I never opened after the exam. I wanted practice questions for interview prep. Writing them by hand would take a weekend. Pasting chunks into a chat window works, but it's manual, and after ten tabs I lose the thread.
So I set a different goal. One script, one folder of notes, one week. The script would read each Markdown file, split it into chunks, turn every chunk into flashcards, and save them as JSON. A scheduled job on a server would run it every morning while my laptop slept. And I would write down everything that broke, because something always breaks. The question I wanted to answer: can a free model tier and a free server actually carry a real, scheduled project for a week?
That's where MonkeyCode came in. It's an open-source project that offers free model access and a free server for exactly this kind of experiment. The free tier advertises a 10-million-token allowance — check the README for the current number, because free tiers change and I don't want you trusting a blog post. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I'll show you the exact script and the exact failures, and you can run the same test on your own notes.
The setup
You need Python 3.11 or newer, the httpx library, a folder of Markdown notes, and an API key from the MonkeyCode project docs. Keep the key in an environment variable. I'll say that again slowly: never put the key in the script.
The script has three parts. A chunker, because my notes are too long for one prompt. A model call, because that's the whole point. And a parser, because models love returning JSON wrapped in markdown fences.
# make_cards.py
import json
import os
import time
from pathlib import Path
NOTES_DIR = Path("notes")
OUT_FILE = Path("cards.json")
CHUNK_SIZE = 800
def chunk_text(text: str, size: int = CHUNK_SIZE) -> list[str]:
chunks, current = [], []
length = 0
for line in text.splitlines():
current.append(line)
length += len(line) + 1
if length >= size:
chunks.append("\n".join(current))
current, length = [], 0
if current:
chunks.append("\n".join(current))
return chunks
The model call uses an OpenAI-compatible chat endpoint, which most free tiers speak. Set API_BASE and API_KEY from the MonkeyCode docs.
def ask_model(prompt: str) -> str:
import httpx
resp = httpx.post(
f"{os.environ['API_BASE']}/chat/completions",
headers={"Authorization": f"Bearer {os.environ['API_KEY']}"},
json={
"model": os.environ.get("MODEL_NAME", "default"),
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.4,
},
timeout=120,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
The prompt is short and strict. I ask for JSON only, no markdown, answers under forty words.
PROMPT = """Turn this lecture chunk into flashcards.
Return ONLY a JSON array. No markdown, no commentary.
Each item: {"question": "...", "answer": "..."}
Answers must be under 40 words and answerable from the chunk.
Chunk:
{chunk}
"""
Then the part that saved me: a parser with a retry loop. If the model returns markdown instead of JSON, the parser returns an empty list, and the loop asks again.
def parse_cards(text: str) -> list[dict]:
text = text.strip()
if text.startswith("```
"):
text = text.strip("`").strip()
if text.startswith("json"):
text = text[4:].strip()
try:
data = json.loads(text)
return data if isinstance(data, list) else []
except json.JSONDecodeError:
return []
def cards_for_chunk(chunk: str, retries: int = 2) -> list[dict]:
for attempt in range(retries + 1):
raw = ask_model(PROMPT.format(chunk=chunk))
cards = parse_cards(raw)
if cards:
return cards
print(f" parse failed on attempt {attempt + 1}, retrying...")
time.sleep(5)
return []
def main() -> None:
all_cards, seen = [], set()
for path in sorted(NOTES_DIR.glob("*.md")):
print(f"processing {path.name}")
for i, chunk in enumerate(chunk_text(path.read_text())):
for card in cards_for_chunk(chunk):
key = card["question"].lower().strip()
if key not in seen:
seen.add(key)
all_cards.append(card)
OUT_FILE.write_text(json.dumps(all_cards, indent=2))
print(f"wrote {len(all_cards)} cards to {OUT_FILE}")
if __name__ == "__main__":
main()
```
On the free server, one cron line runs it every morning:
``{% endraw %}{% raw %}`conf
0 8 * * * cd /home/me/flashcards && python make_cards.py >> run.log 2>&1
```
Expected output after the first run: a `cards.json` that looks like this.
``{% endraw %}{% raw %}`json
[
{
"question": "What is the difference between L1 and L2 regularization?",
"answer": "L1 can shrink weights to exactly zero; L2 shrinks them but rarely to zero."
}
]
```
## What happened, day by day
Day one was boring, which is the highest compliment I can give a script. Three chapters in, fifty-one cards out, clean JSON.
Day two, I added the chapter with the big comparison table. The model stopped returning JSON and started returning markdown tables. My parser returned an empty list, the retry fired, and the second attempt came back clean. Without that loop, a whole chapter would have vanished silently. That's the first lesson: the parser is the real product, not the prompt.
Day three was the queue. Free model tiers queue requests when demand spikes, and my script hit a timeout at 2am. When else would a script fail? The fix was a retry with backoff around the HTTP call. It took me an hour of staring at logs to admit the model wasn't broken. My assumptions were.
Day four was the cold start. The free server sleeps when nothing touches it, so the first request of the morning took about forty seconds instead of two. My 120-second timeout absorbed it, but I learned to schedule the job for 8am and check the log at 9am, not the other way around.
By day seven I had 214 unique flashcards from six chapters. Total usage for the week: just over a million tokens, input and output combined. That's a tenth of the advertised allowance. Your numbers will differ, because your notes are longer and your prompts are different — measure your own.
## The input that broke everything
The table-heavy chapter was the one that broke the parser. Here's the shape of the failure: the model saw a markdown table in the source, decided tables were the right output format, and wrapped its answer in fences. My parser returned `[]`, and the retry saved the day. The fix wasn't a better model. It was a loop that treated bad output as normal.
## Lessons
There's a running discussion on DEV about how not all AI builders are doing the same work. This week convinced me the split is real: some of us are tuning prompts, and some of us are debugging JSON at 2am.
Free infrastructure isn't worse, it's different. Queues and cold starts look like bugs, but they're just rate-limited reality, and every API you meet in a real job behaves the same way. Design for them once and you stop being surprised.
The model is the easy part. I spent maybe ten percent of my debugging on prompts and ninety percent on JSON parsing, retries, and file paths. That ratio is normal. Embrace it.
A free server changes your workflow in a way a free API alone can't. The job runs whether I remember to run it or not. That's the actual value of the free server: not specs, but the fact that my laptop can be closed and the work still happens.
## Who should not use this
If you need guaranteed latency for real users, don't build on a free tier. If your data is sensitive, don't send it to a third-party endpoint. If you need a model with a specific benchmark score, verify it yourself before trusting a README. This setup is for learners, hackathon demos, and personal tools where "it worked this morning" is good enough.
## Try it
Run the script on your own notes and see what breaks. I genuinely want to know — the failure you hit will probably be one I never saw. The project README has the setup steps, and the whole experiment costs you nothing but a week of logs.
Extension: once `cards.json` exists, implement SM-2 spaced repetition on top of it. The data model is already there — you just need a review queue and a score per card.
Top comments (0)