Lesson 6 of 10

Build a Minimal Standing Agent

ProjectFree to read · saving needs Core12 min · Remember → Retrieve → Act → Report · Updated 2026-09-16
You end this lesson with: A minimal standing agent that remembers, retrieves, and acts through one tool.

Related lectures: Lesson 01. What a Standing Agent Is · Lesson 04. Tools and Integrations · Lesson 05. The Skill Library

You have identity.md from lesson 2, a search function from lesson 3, a tool wrapper from lesson 4, and skill files from lesson 5. Four working pieces, four separate files, nothing that runs them in order. If someone asked you to run "your agent" right now, you would have to open four files and simulate the handoff between them by hand.

That gap is where personal-agent projects usually die: pieces that work in isolation but were never wired into a loop. This project closes it. You write one entry script that runs Remember → Retrieve → Act → Report end to end. When you are done, one command is a complete standing agent: small, and doing exactly one useful thing.

What You Build

  • agent/memory/identity.md and agent/memory/episodic.md: the memory layer.
  • agent/retrieval/search.py: hybrid (keyword + recency) search over the episodic log.
  • agent/tools/github_prs.py: one wrapped integration; the only file allowed a credential.
  • agent/skills/triage-prs/SKILL.md and agent/skills/log-entry/SKILL.md: two skills, one shared format.
  • agent/run.py: the single entry point that runs the loop.

Deliverable: a minimal standing agent that remembers, retrieves, and acts through one tool.

run.py is the piece that didn't exist before this project: everything else was already built lesson by lesson, sitting unwired.

diagram
drawing
run.py is the one script that wires four previously separate files into a single loop.

Four boxes, one script, in a fixed order — the same shape Lesson 1 drew for the loop in general, now with this project's actual file names in it.

The rule this lab tests

Wire Remember, Retrieve, Act, and Report into one script when a task must run unattended and a later run needs to see what an earlier one did — a schedule can only trigger the whole loop, not one stage of it, and retrieval only has something to find once report has already written it. Keep the pieces separate, and run each by hand, while you are still developing or debugging a single stage in isolation — wiring too early just makes it harder to tell a bug in that one piece apart from a bug in the loop around it.

The observable test sits in your own run: the two python agent/run.py --mock runs in Acceptance Checks below either produce the (context: ...) line on the second run, or they don't. If it appears, retrieval found what the first run's report wrote, and wiring the four pieces together earned its keep. If it's missing, one of them is still behaving as if it ran alone.

SituationWire it into one scriptKeep it separate
Lesson 4's tool wrapper, still being checked against a live APINo — run python agent/tools/github_prs.py --mock by itself until its output is trustworthyYes
The finished loop, meant to fire on a scheduleYes — a schedule can only invoke run.py, not four files in sequenceNo
Lesson 5's skill files, still being written and eval'dNo — run the eval script against the skill files directlyYes, until eval passes

Prerequisites

  • Lesson 1: a filled-in agent/REQUIREMENTS.md and a stated trust boundary. Lessons 2-5 concepts: memory read/write, retrieval, the wrapper pattern, the skill format. You don't need your own files from those lessons, everything you need is below.
  • Python 3.10+, stdlib only, no pip install. Optional GITHUB_TOKEN env var: not required, every step runs in --mock mode without one.

Step-by-Step Tasks

  1. Create the layout: agent/memory/, agent/retrieval/, agent/tools/, agent/skills/triage-prs/, agent/skills/log-entry/.
  2. Copy in identity.md and an empty episodic.md. Fill in the owner and repos-watched lines for yourself.
  3. Copy in retrieval/search.py: the whole retrieval layer, no vector DB, so you can read every line before trusting it with your memory file.
  4. Copy in tools/github_prs.py. Read it once end to end: GITHUB_TOKEN is read once, in this file, and never returned or printed. Only the parsed PR list crosses back.
  5. Copy in both SKILL.md files. Each has a ----fenced frontmatter block (name, description) and a body. That split is what makes progressive disclosure possible.
  6. Copy in run.py. It calls remember, indexes the skills, calls retrieve, calls act through the wrapper, then reports and appends to the episodic log.
  7. Run python agent/run.py --mock twice in a row and read the diff in episodic.md between the two runs.

Acceptance Checks

sh
python agent/run.py --mock   # first runpython agent/run.py --mock   # second run

Expected shape, each run:

Loaded 2 skill(s): log-entry, triage-prs REPORT: 1 PR(s) waiting on you >2 days: - you/example-repo#42: Add retry to webhook handler (context: 1 related past run(s) in episodic memory)

The (context: ...) line only appears once episodic.md already has a prior entry. Missing on the second run means retrieval is not finding what the first run just wrote.

sh
grep -c "^## " agent/memory/episodic.md   # expect 2, after two runs -- one heading per runpython -c "import sys; sys.path.insert(0, 'agent/retrieval')from pathlib import Pathfrom search import searchprint(len(search(Path('agent/memory/episodic.md'), 'PR waiting review')))"   # expect a positive number; 0 would mean nothing was found

Common Failure Modes

SymptomLikely causeFix
AssertionError: identity.md is emptyStep 2 skippedFill in at least the Purpose line
json.decoder.JSONDecodeError in act()Wrapper printed non-JSONRun python agent/tools/github_prs.py --mock directly, read raw stdout
search() always returns [], or context line missingHeading not exactly ## YYYY-MM-DDTHH:MM:SS, or episodic.md reset between runsInspect episodic.md for a malformed heading; confirm the file persisted -- report() only appends

What to Submit

  • The agent/ directory after task 7, including episodic.md with at least two real run entries, plus console output of two consecutive python agent/run.py --mock runs.
  • One paragraph: if you wrapped a real tool instead of GitHub, name it and state exactly what stays inside the wrapper file and never reaches run.py.

Key Takeaways

  • Wire Remember, Retrieve, Act, Report into one script; scattered files don't make an agent.
  • Keep the credential in one wrapper file; run.py never touches GITHUB_TOKEN directly.
  • Let the skill format's frontmatter/body split enable progressive disclosure from day one.
  • Test idempotence by running twice; confirm retrieval finds what the prior run just wrote.
  • Prefer silence over noise: report only PRs waiting more than two days.

What You Have Now

A minimal standing agent that remembers, retrieves, and acts through one tool. python agent/run.py is the whole system: it loads what the agent knows, searches its own history for what's relevant, calls exactly one wrapped tool, and writes back what happened.

Example Files

agent/memory/identity.md

Stable facts, loaded whole every run: the "Remember" stage.

markdown

Identity

Owner: <your name> Purpose: track open pull requests waiting on my review, and tell me only when one has waited more than 2 days. Repos watched: you/example-repo Reporting rule: silent if nothing is stale; otherwise list each stale PR once.

agent/memory/episodic.md

Append-only. run.py writes to this; you never edit it by hand.

markdown

Episodic Memory

Append-only. Each run appends one ## <ISO timestamp> entry below this line. Do not hand-edit past entries. Retrieval's recency scoring assumes the log stays chronological.

agent/retrieval/search.py

Hybrid search: keyword overlap plus recency decay, combined by a weight.

python
"""Hybrid search over agent/memory/episodic.md: keyword overlap + recencydecay. No embeddings, no vector DB -- read every line before you trust it."""import math, refrom datetime import datetime, timezonefrom pathlib import PathHEADER = re.compile(r"^## (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})$", re.MULTILINE)def search(path: Path, query: str, k: int = 3, keyword_weight: float = 0.7) -> list[str]:    text = path.read_text(encoding="utf-8") if path.exists() else ""    matches = list(HEADER.finditer(text))    entries = [        (datetime.strptime(m.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc),         text[m.start():(matches[i + 1].start() if i + 1 < len(matches) else len(text))].strip())        for i, m in enumerate(matches)    ]    if not entries:        return []    q_terms = set(re.findall(r"[a-z0-9]+", query.lower()))    now = datetime.now(timezone.utc)    scored = []    for ts, body in entries:        b_terms = set(re.findall(r"[a-z0-9]+", body.lower()))        kw = len(q_terms & b_terms) / len(q_terms) if q_terms else 0.0        recency = math.exp(-max((now - ts).total_seconds() / 86400, 0) / 14.0)        scored.append((keyword_weight * kw + (1 - keyword_weight) * recency, body))    scored.sort(key=lambda pair: pair[0], reverse=True)    return [body for score, body in scored[:k] if score > 0]

agent/tools/github_prs.py

The wrapper. The only file in this agent allowed to read GITHUB_TOKEN.

python
#!/usr/bin/env python3"""Wrapper: the only file allowed to hold GITHUB_TOKEN. Run as asubprocess -- callers see only this script's stdout (a JSON PR list),never the token, never the raw API response."""import json, os, sys, urllib.requestMOCK_PRS = [    {"repo": "you/example-repo", "number": 42, "title": "Add retry to webhook handler", "waiting_days": 3},    {"repo": "you/example-repo", "number": 47, "title": "Fix flaky auth test", "waiting_days": 1},]def main() -> None:    token = os.environ.get("GITHUB_TOKEN")    if token and "--mock" not in sys.argv:        url = "https://api.github.com/search/issues?q=is:open+is:pr+review-requested:@me"        req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})        data = json.load(urllib.request.urlopen(req, timeout=10))        prs = [{"repo": i["repository_url"].split("/repos/")[-1], "number": i["number"],                "title": i["title"], "waiting_days": None} for i in data.get("items", [])]    else:        prs = MOCK_PRS    print(json.dumps(prs))if __name__ == "__main__":    main()

agent/skills/triage-prs/SKILL.md

markdown

name: triage-prs description: Decide which open PRs are worth interrupting the owner about.

Use after agent/tools/github_prs.py returns its PR list.

Rule: report a PR only if waiting_days > 2. Reporting every open PR trains the owner to ignore the report.

Output one line per stale PR: <repo>#<number>: <title> (<waiting_days>d). If nothing is stale, report nothing but a one-line confirmation the check ran -- silence is correct, not a failure.

agent/skills/log-entry/SKILL.md

markdown

name: log-entry description: Format one episodic memory entry so retrieval can parse it.

Heading: a level-2 heading holding only an ISO 8601 UTC timestamp -- ## 2026-08-19T09:00:00.

Body: always include Ran: <what ran> and a one-line result summary. Never edit or delete a past entry -- the log is append-only.

End with one blank line before the next ## heading, or agent/retrieval/search.py merges two entries into one.

agent/run.py

The entry point. Wires Remember, Retrieve, Act, Report into one run.

python
#!/usr/bin/env python3"""Single entry point. Run: python agent/run.py [--mock]One pass of Remember -> Retrieve -> Act -> Report, then exit. No modelcall here -- point one at act()/report() once you're ready to replacethe hardcoded triage-prs rule with judgment."""import json, re, subprocess, sysfrom datetime import datetime, timezonefrom pathlib import PathAGENT_DIR = Path(__file__).parentIDENTITY = AGENT_DIR / "memory" / "identity.md"EPISODIC = AGENT_DIR / "memory" / "episodic.md"FRONTMATTER = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)def list_skills() -> list[dict]:    """Progressive disclosure step 1: name + description only, every run."""    out = []    for path in sorted((AGENT_DIR / "skills").glob("*/SKILL.md")):        meta_block = FRONTMATTER.match(path.read_text(encoding="utf-8")).group(1)        meta = dict(line.split(":", 1) for line in meta_block.splitlines() if ":" in line)        out.append({"name": meta["name"].strip(), "description": meta["description"].strip()})    return outdef retrieve(query: str, k: int = 3) -> list[str]:    sys.path.insert(0, str(AGENT_DIR / "retrieval"))    from search import search    return search(EPISODIC, query, k=k)def act(mock: bool) -> list[dict]:    cmd = [sys.executable, str(AGENT_DIR / "tools" / "github_prs.py")]    result = subprocess.run(cmd + (["--mock"] if mock else []), capture_output=True, text=True, check=True)    return json.loads(result.stdout)def report(prs: list[dict], history: list[str]) -> None:    stale = [pr for pr in prs if (pr.get("waiting_days") or 0) > 2]  # triage-prs rule    ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")    lines = [f"## {ts}", "Ran: triage-prs", f"Open PRs seen: {len(prs)}", f"Waiting >2 days: {len(stale)}"]    lines += [f"- {p['repo']}#{p['number']}: {p['title']} ({p['waiting_days']}d)" for p in stale]    with EPISODIC.open("a", encoding="utf-8") as f:        f.write("\n".join(lines) + "\n\n")    print(f"REPORT: {len(stale)} PR(s) waiting on you >2 days:" if stale else "REPORT: nothing waiting. Staying quiet.")    for p in stale:        print(f"  - {p['repo']}#{p['number']}: {p['title']}")    if history:        print(f"(context: {len(history)} related past run(s) in episodic memory)")def main() -> None:    assert IDENTITY.read_text(encoding="utf-8").strip(), "agent/memory/identity.md is empty -- fill it in first"    skills = list_skills()    print(f"Loaded {len(skills)} skill(s): {', '.join(s['name'] for s in skills)}")    history = retrieve("PR waiting review", k=3)    report(act(mock="--mock" in sys.argv), history)if __name__ == "__main__":    main()

NO-QUIZ

Quiz coming to this lesson

This lesson doesn't have a graded quiz yet — completion is still tracked, just not verified against questions.

Not savedReading every lesson is free; you only pay to save progress and do the labs.See plansSign in