Build a Minimal Standing Agent

Project12 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.

Chapters

  1. What You Build
    01
  2. The rule this lab tests
    02
  3. Prerequisites
    03
  4. Step-by-Step Tasks
    04
  5. Acceptance Checks
    05
  6. Common Failure Modes
    06

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

Project 01. Build a Minimal Standing Agent

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 run
python 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 run
python -c "
import sys; sys.path.insert(0, 'agent/retrieval')
from pathlib import Path
from search import search
print(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 + recency
decay. No embeddings, no vector DB -- read every line before you trust it."""
import math, re
from datetime import datetime, timezone
from pathlib import Path

HEADER = 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 a
subprocess -- callers see only this script's stdout (a JSON PR list),
never the token, never the raw API response."""
import json, os, sys, urllib.request

MOCK_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 model
call here -- point one at act()/report() once you're ready to replace
the hardcoded triage-prs rule with judgment."""
import json, re, subprocess, sys
from datetime import datetime, timezone
from pathlib import Path

AGENT_DIR = Path(__file__).parent
IDENTITY = 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 out

def 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.

Reading is free — your progress isn't being saved

Nothing you did on this page is recorded. Paid students get saved progress and verified completions, recorded quiz answers, the spaced-repetition review queue, labs, and a certificate for each course they finish.

30-day money-back guarantee. One-time payment — lifetime access and updates.