TodoList Agent Follow-ups
Built on due reminders, task rows and agent access. The TodoList holds state that outlives any session, acts on a clock through its reminders and reaches a phone through web push; a Claude Code session has none of the three, so a follow-up it noticed and left alone — "the same guard is missing in the sibling router", "the docs page still names the old flag" — used to survive only in its scrollback. A session now writes each one into the owner's TodoList as it finds it, the owner reads them with everything else, and a session in the repository drains them again, one change at a time, until the work converges.
No model runs inside Esposter. The model stays in the terminal; Esposter stores the follow-ups and shows them, and its reminders and live sync do the rest.
flowchart LR
subgraph Terminal["Claude Code, any repository"]
HOOK["SessionStart hook — list, repository, session, time zone"]
CAP["capture skill — a follow-up the moment it is found"]
DRAIN["drain skill — take, do, tick, repeat"]
end
subgraph App["Esposter app"]
MCP["/api/mcp — an API key, and the procedures that opt in"]
SAVE["saveResourceContent — the one content door"]
end
HOOK --> CAP
CAP -->|"addFollowUp"| MCP
DRAIN -->|"readFollowUps · completeFollowUp · handBackFollowUp"| MCP
DRAIN -->|"a new follow-up found mid-drain"| CAP
MCP --> SAVE
SAVE -->|"onSaveResourceContent"| PAGE["the open Items blade, live"]
SAVE -->|"after-save hook"| REM["due reminders, web push"]
Why this and not more products
The owner keeps one TodoList, holding everything to do, and works at a desk nine times in ten. That ruled out the other candidates for the next piece of product work: the cross-list views stay deferred because their trigger never fires with one list (smart lists, global calendar), and phone capture or natural-language dates save little for someone at a desk (quick add). At the desk, the place a todo is born is a terminal session, so the missing piece was the route from that terminal to the list.
It also sidesteps the platform decision AI resource generation waits on, the first LLM dependency inside Esposter: every model call is the owner's own Claude Code session, and Esposter serves plain tools over data it already stores.
The origin
A todo a session wrote carries an origin, and one the owner wrote carries none:
interface TodoListItemOrigin {
// Set once a drain hands it to the owner, and absent while an agent may still take it
handedBackAt?: Date;
// `owner/name` from the origin remote, so the same repository matches from every clone and machine
repository: string;
// The Claude Code session that wrote it, for `claude --resume`
sessionId: string;
}
A todo with an origin is a follow-up, and otherwise an ordinary todo: it sorts, stars, dates, reminds and prints like the rest. Its row's metadata line names the repository, and its edit dialog shows where it came from with a copy button for claude --resume <sessionId>, noting that a resume works only on the machine holding the session and from its project folder.
The follow-up procedures
Four procedures on the TodoList router, each opted into the MCP bridge with a description written for the agent, and each taking the list's id and the owner guard every TodoList procedure has.
| Procedure | Input | Does |
|---|---|---|
addFollowUp | name, plain-text notes, optional due date, repository, session | appends an open follow-up at the foot of the list, as quick add does, its notes escaped into the editor's HTML; answers with its id |
readFollowUps | repository | the open follow-ups of that repository not handed back, in the list's manual order |
completeFollowUp | follow-up id, a line on what was done, IANA time zone | ticks it as the checkbox does, so a repeating one rolls to its next due date in that zone, and appends the line to its notes |
handBackFollowUp | follow-up id, the reason | sets handedBackAt and appends the reason to its notes |
- Only a follow-up is in reach. Each acts on a todo with an
origin, open and not handed back: an id naming a todo the owner wrote answers not found before anything is saved, so no key ticks or annotates it, and there is no delete. - Read, changed and saved on the server. Where the browser saves the whole list it holds, each of these reads the list, changes the one follow-up and saves the list at the version it read. A save the owner made in between makes that save stale, and the list is read again and the change reapplied, a bounded number of times, rather than written over.
- A repeating follow-up waits for its date. Ticking one rolls it forward and leaves it open, so
readFollowUpsholds it back until its next due date instead of handing it straight back to a drain: one take per occurrence. - Everything written is validated by the same
todoListItemSchemaa browser save is.
The plugin
The endpoint, the session's values and the skills ship as one Claude Code plugin, packages/follow-ups, laid out as the persona plugin is and listed in the same marketplace, so one install reaches every repository on the machine. The list's Connect an agent button opens the steps: create a key under API keys in the settings, install the plugin, and give its install the site, the list's id and the key, each with a copy button.
packages/follow-ups/
.claude-plugin/plugin.json ← userConfig: the site, the list's id, and the API key marked sensitive
.mcp.json ← the site's MCP route over the Streamable HTTP transport, the key as its bearer token
hooks/hooks.json ← SessionStart: the list, the repository, the session and the time zone into context
skills/capture/SKILL.md ← what a follow-up is, and writing one
skills/drain/SKILL.md ← the loop that works through them
- The key lives in the credential store. It is a
userConfigvalue marked sensitive, which Claude Code keeps in the system credential store rather than a settings file, and.mcp.jsonreads it into theAuthorizationheader. - The session's values come from a hook. A tool call cannot see which session made it, but every hook's input names the session, so the
SessionStarthook reads the session id, the origin remote asowner/name, the machine's time zone and the configured list's id, and adds them to the session's context for the tools to be passed unchanged. A folder with no origin remote says so, and the capture skill writes nothing there, since nothing could drain it. - It has no dependencies. The hook is one TypeScript file node runs directly, so an install runs no package install.
What counts as a follow-up
The capture skill is what keeps the list from filling with noise, so its rules are strict:
- Work the session saw and left undone because it was outside the change in hand, written as an instruction a cold session can act on, with the files named in the notes.
- Never what the session could finish now. A repository whose rules have a finding fixed by the change that finds it gets it fixed, and a follow-up is not a way around that rule.
- Never what needs a design. In Esposter that is a proposal (engineering loops); elsewhere the skill tells the owner in its reply instead.
- Written when found, not at the end. A session ends in many ways, and one interrupted or compacted before a closing step loses everything held for it.
- One todo each, never several in one todo's notes, since a drain completes one follow-up per change.
The drain
Captured follow-ups are half the loop; the plugin's drain skill does them. A session runs it in a repository and it keeps going until that repository has none left.
flowchart TD
START["drain starts — note the open count as the checkpoint"] --> LIST["readFollowUps for this repository"]
LIST --> EMPTY{"any left?"}
EMPTY -->|"none"| DONE["stop — report what was done"]
EMPTY -->|"yes"| DUE{"taken as many as the checkpoint since it was set?"}
DUE -->|"no"| TAKE["take the first, in the list's order"]
DUE -->|"yes"| CONV{"fewer open than the checkpoint?"}
CONV -->|"no"| STALL["stop — report the list is not converging"]
CONV -->|"yes"| RESET["the open count becomes the checkpoint"]
RESET --> TAKE
TAKE --> FITS{"one change, no design, nothing spent?"}
FITS -->|"no"| BACK["handBackFollowUp with the reason"]
FITS -->|"yes"| WORK["do it through the repository's change loop"]
WORK --> FOUND["new follow-ups found — captured as they appear"]
WORK --> TICK["completeFollowUp with the commit"]
BACK --> LIST
TICK --> LIST
- One follow-up per change, done the way the repository says any change is done: in Esposter, the finishing ritual, the checks, a commit by pathspec and a queue push; elsewhere, that repository's own instructions. It is ticked only once the commit exists, with a line naming the commit.
- The order is the owner's.
readFollowUpsanswers in the list's manual order, so dragging a follow-up to the top is how the owner says what goes first. - Fresh reads every turn. The list is read again after every follow-up, never cached, so a follow-up the owner ticked, deleted or reordered while the drain ran is respected on the next turn.
Handing back
The drain takes only what it may do alone. It hands a follow-up back when doing it would need a design decision or a choice between readings its notes leave open; anything spent outside the repository's review queue — opening a pull request, pushing a protected branch, a paid service, a destructive change to shared infrastructure; or more than one change.
A handed-back follow-up stays in the list as an ordinary open todo, its row marked handed back and the reason appended to its notes, and it leaves readFollowUps, so the drain does not take it again. Once the owner has answered it, Return to the drain in its edit dialog clears the handback.
The stop rule
The drain stops in exactly two cases:
- Nothing is left — every follow-up for the repository is done or handed back.
- The list is not shrinking. Draining one follow-up can capture new ones. The drain notes the open count when it starts, as its checkpoint, and once it has taken that many, an open count no lower than the checkpoint means the work is producing follow-ups as fast as it closes them. It stops and says so, leaving everything open for the owner. A lower count becomes the checkpoint and the check repeats, so a drain that shrank once cannot grow unchecked afterwards.
It is the convergence test every loop in the repository uses: each pass should find less than the one before (engineering loops).
Running unattended
A drain can run while the owner is away, which is what makes it worth having. It runs in whatever permission mode its session has and relies on the repository's own safety net rather than one of its own: in Esposter every change it pushes lands on the review queue and passes the collector's review before reaching develop (review collector). In Esposter the drain is also a step of what a session runs next when nothing is asked, after a red collector run and before an area owed a product review, since the owner wrote each follow-up down or accepted a session writing it.
A long drain runs in one session, so its context grows with every follow-up and relies on automatic compaction. A fresh session per follow-up, started by the agent console's host, would keep each one clean — a later step, once a drain has been seen to degrade.
What is deliberately not in it
- A model inside Esposter. No summarising, ranking or generating in the app: a session does the thinking, the app keeps the state.
- A second list for agents. Follow-ups go into the one list the owner reads, filtered by
originwhen a session asks for them. - Running sessions from the app. A session the user started does the work; a fresh session per follow-up is the later step the drain's notes name.
Key files
| File | Role |
|---|---|
apps/web/shared/models/resource/todoList/TodoListItemOrigin.ts | the origin, and the owner/name rule its repository keeps |
apps/web/server/trpc/routers/todoList.ts | the four follow-up procedures, opted into the MCP bridge |
apps/web/server/services/resource/todoList/updateTodoListContent.ts | read, change and save at the version read, reapplied over a concurrent save |
apps/web/server/services/resource/todoList/checkIsOpenFollowUp.ts | the line between the owner's todos and a session's |
apps/web/app/components/Resource/TodoList/Origin.vue | where a follow-up came from, its resume command, and clearing a handback |
apps/web/app/components/Resource/TodoList/ConnectAgentButton.vue | the steps that connect a session to this list |
packages/follow-ups/scripts/start.ts | the SessionStart hook |
packages/follow-ups/skills/drain/SKILL.md | the loop and its stop rule |
apps/web/content/docs/architecture/engineering-loops.md | the drain among the work a session picks up unasked |
packages/follow-ups/skills/capture/SKILL.md | what counts as a follow-up |
Sources
- Claude Code — plugin manifest reference — a plugin bundling an HTTP MCP server, hooks and skills, a
userConfigvalue markedsensitivekept in the platform's credential store,${user_config.KEY}substituted into an HTTP server'sheaders, andCLAUDE_PLUGIN_OPTION_<KEY>in a hook's environment.