Planning
Planning in Deep Agents is one tool and one state key. TodoListMiddleware registers a write_todos tool and declares todos on the graph state, and every call to that tool replaces the whole list. A panel that renders todos therefore shows the plan the agent is working from, including the revisions it makes while the run is still going. The running example is an aviation dispatch desk, and this page walks the three files behind it.
What the demo does
The Run tab shows the prebuilt <chat> composition beside a plan panel. The welcome suggestion asks for a dispatch brief from KSFO to KASE, and the agent writes its todo list before it looks anything up: the panel fills with pending rows, one row flips to in progress while the matching lookup runs, then that row completes and the next one starts. Aspen is the interesting half of that route, a field at 7,820 ft with a mountain wave advisory, so the agent usually appends a step once the data comes back and the panel changes shape mid-run. A route with no notable constraints usually produces a short list, so ask for a brief on such a route to watch a plan run straight through instead.
How it is built
Three files plus the system prompt carry the capability: a Python graph holding the lookup tools and the todo middleware, an application config, and the Angular component that projects todos into a panel. Open the Code tab to read them in place.
The tools the plan is made of
Each lookup answers one question about one airport from a fixed table, so a recorded run stays stable. The plan the agent writes is a sequence of these calls.
@tool
def lookup_field_elevation(airport: str) -> str:
"""Return the field elevation in feet for a four-letter ICAO airport code."""
elevation = FIELD_ELEVATION_FT.get(airport.upper())
if elevation is None:
return f"No field elevation on file for {airport.upper()}."
return f"{airport.upper()} field elevation is {elevation} ft."
@tool
def lookup_runway_length(airport: str) -> str:
"""Return the longest runway length in feet for a four-letter ICAO airport code."""
length = RUNWAY_LENGTH_FT.get(airport.upper())
if length is None:
return f"No runway data on file for {airport.upper()}."
return f"{airport.upper()} longest runway is {length} ft."
@tool
def lookup_weather(airport: str) -> str:
"""Return the current field conditions for a four-letter ICAO airport code."""
conditions = WEATHER.get(airport.upper())
if conditions is None:
return f"No observation on file for {airport.upper()}."
return f"{airport.upper()}: {conditions}"
Building the agent on TodoListMiddleware
create_deep_agent assembles a LangGraph agent from a middleware stack. TodoListMiddleware is not one of the entries it assembles on its own, so the demo passes it: that single entry registers the write_todos tool and adds todos to the state schema.
def build_planning_agent():
"""Build the planning agent.
`TodoListMiddleware` is passed explicitly; `create_deep_agent` does not
install it on its own.
"""
return create_deep_agent(
model=ChatOpenAI(model="gpt-4.1", temperature=0),
tools=[lookup_field_elevation, lookup_runway_length, lookup_weather],
system_prompt=(PROMPTS_DIR / "planning.md").read_text(),
middleware=[TodoListMiddleware()],
)The middleware also appends its own usage guidance to the system message on every model call, so the model receives instructions about the tool that the demo never wrote.
The prompt that makes the plan visible
The middleware supplies a tool, not a policy, and its tool description tells the model to skip the list when a request takes fewer than three steps. A demo whose whole point is a visible plan cannot leave that to chance, so the system prompt in prompts/planning.md is explicit about when to call the tool and how small each transition should be.
Your first action on any request is a call to `write_todos`. Do not call a
lookup tool before the todo list exists. Write one todo per step.
Mark exactly one todo `in_progress` before you start it and mark it `completed`
the moment it is done. Call `write_todos` again for each transition — do not
batch several completions into one call, and do not call `write_todos` in
parallel with itself.The tool replaces the entire list, so two calls in the same turn would be ambiguous about which one wins. TodoListMiddleware checks the assistant message after every model call and, when it finds more than one write_todos call, answers all of them with an error instead of applying any of them. The prompt asking for one transition at a time keeps the run away from that path.
Providing the agent
provideAgent() from @threadplane/langgraph registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its connection at runtime because the host that serves the demo decides which runtime is attached; your own application passes apiUrl and assistantId directly.
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
};
}),
],
};The shape of one todo
A todo is two fields. There is no identifier, no timestamp, and no separate present-tense label, so the component declares the shape it is willing to render and keeps the three valid statuses next to it.
/**
* One entry of the `todos` list written by the `write_todos` tool.
*
* `deepagents` 0.7.11 emits exactly two fields. There is no identifier and no
* separate present-tense label, so the panel tracks rows by index.
*/
interface Todo {
content: string;
status: 'pending' | 'in_progress' | 'completed';
}
const TODO_STATUSES: ReadonlyArray<Todo['status']> = ['pending', 'in_progress', 'completed'];Projecting todos into a signal
injectAgent() returns the agent, and agent.value() is a signal holding the latest graph state, so the panel is a computed() over that state and nothing more. The status is narrowed against the known list on the way through, because a graph state key is not a typed contract and an unexpected value would otherwise reach a template that switches on it.
/** Live projection of `state.todos`, normalized against unknown statuses. */
protected readonly todos = computed<Todo[]>(() => {
const todos = (this.agent.value() as Record<string, unknown> | undefined)?.['todos'];
if (!Array.isArray(todos)) return [];
return todos.map((todo) => {
const entry = todo as Record<string, unknown>;
const status = entry['status'] as Todo['status'];
return {
content: String(entry['content'] ?? ''),
status: TODO_STATUSES.includes(status) ? status : 'pending',
};
});
});
protected readonly completedCount = computed(
() => this.todos().filter((todo) => todo.status === 'completed').length,
);completedCount derives from todos rather than from the state again, so the progress line and the rows can never disagree.
The plan panel
The panel renders the array it was given: an empty state before the first write_todos call, a progress line, and one row per todo. Status drives the icon through a @switch and the row styling through a data-status attribute, which keeps the status-to-style mapping in CSS instead of spreading a second copy of the enum through the template. Rows track by $index because a todo carries no identifier; tracking by content would be worse rather than better, since content is exactly what changes when the agent rewrites a step.
<div sidebar class="panel">
<h3 class="cap">Plan</h3>
@if (todos().length === 0) {
<p class="empty">No plan yet</p>
} @else {
<p class="progress" data-testid="todo-progress">
{{ completedCount() }} of {{ todos().length }} complete
</p>
}
@for (todo of todos(); track $index) {
<div class="todo" data-testid="todo-row" [attr.data-status]="todo.status">
<span class="todo__icon">
@switch (todo.status) {
@case ('completed') {
<span class="todo__icon--done">✓</span>
}
@case ('in_progress') {
<span class="todo__icon--active">◠</span>
}
@default {
<span>○</span>
}
}
</span>
<span class="todo__text">{{ todo.content }}</span>
</div>
}
</div>How todos reach the browser
todos travels as graph state, not as a custom event. The adapter subscribes to the values, messages-tuple, updates, and custom stream modes by default, and each values payload becomes the new agent.value(). Nothing in the application subscribes to anything, and nothing merges: because write_todos replaces the list, the panel renders whatever the last snapshot held.
The middleware declares todos as state the graph emits rather than as input a caller supplies. The plan is therefore something to read and render, never something the panel writes back — the model owns it from the first call to the last.