hubagenticai

Tutorials Beginner · 8/8

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.

updated 2026-10-08 ⏱ 40 min

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:

  1. A collection point it drains (a folder, an inbox, a channel).
  2. A brain that decides what each item is and where it goes.
  3. 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.

newsletter

One practical agentic-AI guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.

Quick Jump: