Skip to Content
DocsToolsLiving plan

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_plans

recall_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 run

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

Last updated on