Living plan (0.9.42+)
A living plan is a small mutable list of steps the agent rewrites atomically on every change. It’s the Claude Code TodoWrite pattern. The plan becomes the load-bearing artifact in the conversation: every plan-tool call returns the full rendered plan back as the tool result, so the model re-orients on the current state every time it touches the plan.
Turn it on with one kwarg:
from loomflow import Agent
agent = Agent(
"You are a careful agent. Use the living plan religiously.",
model="claude-opus-4-7",
living_plan=True,
)That installs two tools and augments the system prompt with the TodoWrite discipline.
The two tools
plan_write(goal, steps) rewrites the full plan atomically.
You pass the complete updated list of steps every call. Each step
is a dict:
{
"description": "Run the validator and confirm pass",
"status": "doing", # todo | doing | done | blocked | skipped
"finding": "exit 0, 14 tests passed", # optional, 1-line note
}It returns the rendered plan as a markdown table. That table is the agent’s source of truth for what’s next.
plan_read() returns the current plan without changing it. Use
it to re-orient. Most of the time the return value of plan_write
is enough, so plan_read gets called rarely.
Why atomic full-list rewrites
The obvious alternative is fine-grained deltas:
plan_update_step(2, "done"). The full-rewrite approach beats it
on three counts:
- Forced engagement. The model has to serialize the whole plan on every change. A dropped step is visible immediately.
- No partial-update bugs. One tool call replaces the whole state. No “did I insert before or after the doing step?”.
- Drift becomes structural. Every action maps to a step the agent itself wrote. The plan isn’t a frozen contract, it’s a living document the agent maintains.
Status values
Five canonical statuses: todo, doing, done, blocked,
skipped. The model often invents synonyms, so common ones get
normalized automatically: in_progress and wip become doing,
completed and finished become done, failed and stuck
become blocked. Anything unrecognized falls back to todo, which
is the safe choice since it keeps the step active.
Lenient step input
The steps argument accepts four shapes, because provider
serializations vary:
- A native
list[dict]. The ideal shape. - A JSON string of a list:
'[{"description": ...}, ...]'. - A JSON string of an object:
'{"steps": [...]}'. - Free-form numbered text:
'1. step a\n2. step b'.
Whatever the model emits, the tool coerces it. If coercion fails, the tool returns an actionable error string the model can recover from, rather than raising.
Workspace mirror
When you wire a workspace alongside
living_plan=True, every successful plan_write also mirrors the
plan to a kind="plan" note. A third tool appears:
recall_past_plans(query).
from loomflow import Agent, InMemoryWorkspace
agent = Agent(
"...",
model="claude-opus-4-7",
workspace=InMemoryWorkspace(),
living_plan=True,
)
# tools wired: plan_write, plan_read, recall_past_plansrecall_past_plans searches prior runs’ plans by free-text query.
A new task can call it at the start with key terms (“conda env
conflict”, “pytest pin”) and bootstrap from a plan that already
worked on similar ground. Past plans show which strategies
succeeded and which got blocked. That’s a real head start.
The mirror is best-effort. Disk full, permissions, transient I/O, none of it fails the tool call. The in-memory plan is the source of truth; the mirror is a bonus that persists across runs.
Keep the plan and the research separate. When a workspace is
wired, the prompt nudges the model to keep two artifacts distinct.
The plan (plan_write) is the strategy and todo list. The
finding field on each step is a one-line summary only. The
research (note(kind="finding", ...)) is the actual collected
information: analysis, evidence, error diagnoses. Substantive
content goes in a note, with a one-line pointer in the step’s
finding. The plan stays scannable, the research log stays
complete, and both persist for future tasks.
Pre-seeding a plan
Pass a constructed LivingPlan instead of True and the run
starts with that plan in place, instead of the model having to
write one first:
from loomflow import Agent, LivingPlan, LivingPlanStep
seed = LivingPlan(
goal="Migrate the auth middleware",
steps=[
LivingPlanStep(description="Audit current sessions table", status="todo"),
LivingPlanStep(description="Write the migration", status="todo"),
LivingPlanStep(description="Run the validator and confirm pass", status="todo"),
],
)
agent = Agent("...", model="claude-opus-4-7", living_plan=seed)Reading the plan from code
Custom architectures and hooks can inspect the active plan with
get_active_plan():
from loomflow.tools.plan import get_active_plan
plan = get_active_plan() # LivingPlan, or None if not enabled this runUseful for a hook that emits a telemetry event when a step
transitions to done, or an architecture that injects the rendered
plan into the next user message. The plan is stored on a contextvar
scoped to one agent.run(), so concurrent runs on the same Agent
instance have isolated plans.