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
- What You Build01
- The rule this lab tests02
- Prerequisites03
- Step-by-Step Tasks04
- Acceptance Checks05
- Common Failure Modes06
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.mdandagent/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.mdandagent/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.
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.
| Situation | Wire it into one script | Keep it separate |
|---|---|---|
| Lesson 4's tool wrapper, still being checked against a live API | No — run python agent/tools/github_prs.py --mock by itself until its output is trustworthy | Yes |
| The finished loop, meant to fire on a schedule | Yes — a schedule can only invoke run.py, not four files in sequence | No |
| Lesson 5's skill files, still being written and eval'd | No — run the eval script against the skill files directly | Yes, until eval passes |
Prerequisites
- Lesson 1: a filled-in
agent/REQUIREMENTS.mdand 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. OptionalGITHUB_TOKENenv var: not required, every step runs in--mockmode without one.
Step-by-Step Tasks
- Create the layout:
agent/memory/,agent/retrieval/,agent/tools/,agent/skills/triage-prs/,agent/skills/log-entry/. - Copy in
identity.mdand an emptyepisodic.md. Fill in the owner and repos-watched lines for yourself. - 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. - Copy in
tools/github_prs.py. Read it once end to end:GITHUB_TOKENis read once, in this file, and never returned or printed. Only the parsed PR list crosses back. - Copy in both
SKILL.mdfiles. Each has a----fenced frontmatter block (name,description) and a body. That split is what makes progressive disclosure possible. - Copy in
run.py. It callsremember, indexes the skills, callsretrieve, callsactthrough the wrapper, thenreports and appends to the episodic log. - Run
python agent/run.py --mocktwice in a row and read the diff inepisodic.mdbetween the two runs.
Acceptance Checks
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.
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
| Symptom | Likely cause | Fix |
|---|---|---|
AssertionError: identity.md is empty | Step 2 skipped | Fill in at least the Purpose line |
json.decoder.JSONDecodeError in act() | Wrapper printed non-JSON | Run python agent/tools/github_prs.py --mock directly, read raw stdout |
search() always returns [], or context line missing | Heading not exactly ## YYYY-MM-DDTHH:MM:SS, or episodic.md reset between runs | Inspect episodic.md for a malformed heading; confirm the file persisted -- report() only appends |
What to Submit
- The
agent/directory after task 7, includingepisodic.mdwith at least two real run entries, plus console output of two consecutivepython agent/run.py --mockruns. - 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.pynever touchesGITHUB_TOKENdirectly. - 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.
Related Lectures
Example Files
agent/memory/identity.md
Stable facts, loaded whole every run: the "Remember" stage.
# 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.
# 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.
"""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.
#!/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
---
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
---
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.
#!/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.