How to build a personal AI agent for daily productivity (note and task triage)
Build an agent that triages your notes into tasks, references, and archives — runnable today with zero API keys, upgradeable to any LLM, and scalable from personal to team use.
The gateway drug of agentic AI isn’t a chatbot — it’s the first piece of your own recurring drudgery an agent takes over. For most people that’s triage: the pile of notes, saved links, and half-thoughts that accumulate faster than they get sorted. We’ll build a triage agent you can run today, then talk about the honest path from personal script to team tool.
Step 1 — The shape of a productivity agent
Every useful productivity agent has the same three parts:
- A collection point it drains (a folder, an inbox, a channel).
- A brain that decides what each item is and where it goes.
- A digest so you trust it without re-checking its work.
That third part is what separates agents people keep using from agents people quietly abandon. Never skip the digest.
Step 2 — Build it
Create triage.py. As always on this site, the brain starts as a
deterministic stand-in so the system works before any API key exists:
import shutil
from datetime import date
from pathlib import Path
INBOX = Path("inbox")
DESTINATIONS = {"task": "tasks", "reference": "reference", "archive": "archive"}
def classify(text: str) -> tuple[str, str]:
"""The brain. Swap this one function for an LLM call later.
Returns (category, one_line_summary)."""
lowered = text.lower()
first_line = text.strip().splitlines()[0][:80] if text.strip() else "(empty note)"
if any(k in lowered for k in ("todo", "deadline", "remind", "by friday", "must ")):
return "task", first_line
if any(k in lowered for k in ("how to", "docs", "reference", "recipe", "https://")):
return "reference", first_line
return "archive", first_line
def triage() -> str:
moves: list[tuple[str, str, str]] = []
for note in sorted(INBOX.glob("*.md")) + sorted(INBOX.glob("*.txt")):
category, summary = classify(note.read_text(encoding="utf-8"))
dest = Path(DESTINATIONS[category])
dest.mkdir(exist_ok=True)
shutil.move(str(note), dest / note.name)
moves.append((note.name, category, summary))
lines = [f"# Triage digest — {date.today().isoformat()}", ""]
if not moves:
lines.append("Inbox was empty. Nothing to do.")
for name, category, summary in moves:
lines.append(f"- **{name}** → `{DESTINATIONS[category]}/` — {summary}")
digest = "\n".join(lines) + "\n"
Path("digest.md").write_text(digest, encoding="utf-8")
return digest
if __name__ == "__main__":
print(triage())
Seed it and run:
mkdir -p inbox
echo "TODO: renew passport, deadline is Friday" > inbox/passport.md
echo "How to tune pgvector indexes: https://example.com/guide" > inbox/pgvector.md
echo "random shower thought about hexagons" > inbox/thought.txt
python triage.py
Three notes leave inbox/, land in tasks/, reference/, and
archive/, and digest.md tells you exactly what happened. The whole
loop — collect, decide, act, report — is running.
Step 3 — Give it a real brain
classify() is the only thing that changes. Here is an LLM version that
works with any OpenAI-compatible chat endpoint: Claude, GPT and Gemini all
offer one, and so does a local model
served by llama.cpp or Ollama. It uses only the standard library. Save it
as llm_triage.py next to triage.py:
import json, os, urllib.request
import triage
PROMPT = """You are a note-triage assistant. Classify the note into exactly
one of: task | reference | archive. Reply with JSON only:
{"category": "...", "summary": "<one line, max 80 chars>"}
Note:
"""
def llm_classify(text: str) -> tuple[str, str]:
body = {"model": os.environ.get("MODEL", "local"),
"messages": [{"role": "user", "content": PROMPT + text}]}
req = urllib.request.Request(
os.environ.get("BASE_URL", "http://127.0.0.1:8080/v1") + "/chat/completions",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ.get('API_KEY', '')}"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
reply = json.load(r)["choices"][0]["message"]["content"]
data = json.loads(reply[reply.find("{"):reply.rfind("}") + 1])
if data["category"] in triage.DESTINATIONS:
return data["category"], str(data["summary"])[:80]
print(f"fallback: unknown category {data['category']!r}")
except Exception as e: # network, JSON, missing key: never crash the 6 a.m. run
print(f"fallback: {type(e).__name__}: {e}")
return "archive", text.strip()[:80] or "(empty note)"
if __name__ == "__main__":
triage.classify = llm_classify # swap the brain; nothing else changes
print(triage.triage())
Seed the inbox again, add a note with no trigger words, and run it:
echo "TODO: renew passport, deadline is Friday" > inbox/passport.md
echo "How to tune pgvector indexes: https://example.com/guide" > inbox/pgvector.md
echo "random shower thought about hexagons" > inbox/thought.txt
echo "Call the dentist tomorrow to move my appointment" > inbox/dentist.txt
BASE_URL=http://127.0.0.1:8080/v1 python llm_triage.py
- **passport.md** → `tasks/` — Renew passport by Friday.
- **pgvector.md** → `reference/` — Guide on tuning pgvector indexes
- **dentist.txt** → `tasks/` — Call dentist tomorrow to reschedule appointment.
- **thought.txt** → `archive/` — shower thought on hexagons
That’s the output from gpt-oss-20b on a local llama-server; your
summaries will be worded differently. The dentist note is the point: the
keyword brain files it in archive/ because it contains none of the
trigger words, and the LLM recognizes it as a task. For a hosted model,
set BASE_URL, MODEL and API_KEY to your provider’s OpenAI-compatible
values.
Two design choices to keep. The contract is identical (category +
summary), and any failure falls back to archive: we stopped the
server and the run printed
fallback: URLError: <urlopen error [Errno 111] Connection refused> and
still filed the note. A triage agent that mis-files into archive is
annoying; one that crashes at 6 a.m. is dead. For a stricter version
with schema validation and retry-with-error, use the pipeline from
structured outputs for agents.
Run it on a schedule (cron, Task Scheduler) and the drudgery is gone.
Step 4 — Extend it toward real life
Each extension is one more tool, same skeleton: a calendar category
that drafts (never sends) an invite file; a collection point that pulls
from your email’s export folder; a weekly roll-up that summarizes the
digests. When the agent needs to reach real apps — your task manager, your
email — that’s exactly the REST-to-MCP wrapper
pattern, which keeps credentials out
of the brain entirely.
Step 5 — From personal to team (the honest path)
The jump from “my script” to “our agent” is bigger than it looks: your teammates’ notes are data you now process, mistakes become visible, and “works on my machine” stops being acceptable. The rule of thumb: a personal agent needs a digest; a team agent needs a digest, an owner, and an undo. The full progression — identity, secrets, review gates, cost — is the adoption ladder piece.
Troubleshooting
Every note prints fallback: URLError … Connection refused
Nothing is listening at BASE_URL, and the agent is doing its
job by falling back to archive. Start your model server, or
point BASE_URL at a hosted endpoint, and check that it ends in
/v1.
Everything lands in archive/
With the keyword brain, that’s expected for notes without trigger words — it’s the safe default, not a bug. With an LLM brain, check your JSON validation isn’t silently falling back on every note (log parse failures — a 100% fallback rate means your prompt or extraction is broken).
shutil.Error: Destination path ... already exists
You re-ran triage with a filename that was already filed. Dedupe on
move — append a timestamp to the name (passport-20260705.md) —
and the agent becomes safely re-runnable.
Where to go next
Before you trust the LLM brain on a schedule, test it: a handful of sample notes with expected categories makes a perfect first golden-task eval. To see the loop underneath every agent like this one, read how an AI agent actually works.
Was this guide useful?
Thanks — noted. It shapes what gets written next.
newsletter
One practical agentic-AI guide in your inbox. No news, no hype.
Tutorials and decision frameworks as they ship. Unsubscribe anytime.