Elements

Todo list

The agent's own working list, rewritten mid-run as it discovers what else is needed.

Todos0/3 · rev 1
  • active
    Read the failing test
  • pending
    Fix the converter
  • pending
    Re-run the suite
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-todo-list"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A todo list shows the plan an agent is working through right now: what is done, what is active, what failed, and what is still pending. With a runtime the list comes from a tool call the model keeps rewriting as it works; standalone you pass the items in yourself.

Getting started

Nothing in the runtime tracks a todo list on its own. The list is ordinary tool output: define a tool whose arguments are the current items, and render it with the TodoList element.

Define the todo tool

A frontend tool is enough here: the model just needs somewhere to write the plan, and the renderer reads it straight off args.

app/toolkit.ts
import { defineToolkit } from "@assistant-ui/react";
import { z } from "zod";
import { TodoList } from "@/components/assistant-ui/elements/todo-list";

export const toolkit = defineToolkit({
  update_todos: {
    type: "frontend",
    description: "Replace the visible working list with the current plan.",
    parameters: z.object({
      items: z.array(
        z.object({
          id: z.string(),
          text: z.string(),
          status: z.enum(["pending", "active", "done", "failed"]),
          reason: z.string().optional(),
        }),
      ),
    }),
    execute: async ({ items }) => ({ count: items.length }),
    render: ({ args }) => <TodoList items={args.items ?? []} />,
  },
});

See Defining tools for toolkit registration and server-side wiring.

Register the toolkit

app/MyRuntimeProvider.tsx
"use client";

import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { toolkit } from "./toolkit";

export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  const runtime = useChatRuntime();
  const config = AuiConfig({ tools: Tools({ toolkit }) });
  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      {children}
    </AssistantRuntimeProvider>
  );
}

Each call to update_todos becomes its own tool-call message part, so a model that revises the plan three times leaves three cards in the transcript. Set display: "standalone" on the entry to keep each one out of the inline chain-of-thought trace and presented as its own block.

Anatomy

<div data-slot="todo-list">
  <div>
    <span>Todos</span>
    <span>{/* n/m, or n/m · rev r when revision is set */}</span>
  </div>
  <ul>
    <li>
      <span>{/* status icon: check, spinner, cross, or empty box */}</span>
      <span>{/* the status as visually hidden text */}</span>
      <div>
        <span>{/* item text */}</span>
        <p>{/* optional failure reason */}</p>
      </div>
    </li>
    {/* one row per item */}
  </ul>
</div>

Each row shows one of four status icons: a checked box for done, a spinning loader for active, a red cross for failed, and an empty outlined box for pending. The icon is decorative; every row carries its status as visually hidden text so a screen reader announces it alongside the item. Failed rows may include a reason below the item text. Text dims and gets struck through when done, reads at full opacity when active, sits at reduced opacity while pending, and uses error styling when failed. Rows key off item.id; passing a new array with the same ids only restyles the existing rows, while a genuinely new id mounts a row that fades and slides in.

A failed item is terminal but it is not a success, so it counts toward the denominator and never toward the numerator: three items that have all settled with one of them failed read 2/3, not 3/3. With zero items the header still renders 0/0 and the list is simply empty; there is no placeholder message. revision is decorative and does not affect the count or the render; it only appends · rev {revision} next to the ratio when you pass a number.

Examples

Item states

The four states rendered together, independent of how the list is produced:

<TodoList
  items={[
    { id: "1", text: "Read the failing test", status: "done" },
    { id: "2", text: "Reproduce locally", status: "active" },
    { id: "3", text: "Write the fix", status: "pending" },
    { id: "4", text: "Run deployment", status: "failed", reason: "Timed out" },
  ]}
/>

Where the list comes from

While the tool call is still streaming, args.items is a partial parse: fields can be missing until the model finishes writing them. useToolArgsStatus reports whether the items argument itself is still arriving, which is enough to dim the whole list without claiming a specific item is settled:

import { useToolArgsStatus } from "@assistant-ui/react";

function TodoToolUI({ args }: { args: { items?: TodoItem[] } }) {
  const { propStatus } = useToolArgsStatus<{ items: TodoItem[] }>();
  return (
    <TodoList
      items={args.items ?? []}
      className={propStatus.items === "streaming" ? "opacity-70" : undefined}
    />
  );
}

Restyle the list

Both lanes take className on the root, and the revision counter uses the shared mono surface from surfaces.tsx.

<TodoList className="max-w-md gap-2" items={items} revision={3} />

API reference

Tool definition

FieldTypeDescription
parametersZod schemaShape of args; use one field carrying the item array.
execute(args) => TResult | Promise<TResult>Runs once the call resolves; the result is separate from what the renderer shows.
renderToolCallMessagePartComponent<TArgs, TResult>Receives args, status, and result for this call; re-runs as args streams in.
display"standalone" | "inline""standalone" surfaces the card outside the chain-of-thought trace. Defaults to "inline".

Tool-call part state

FieldTypeDescription
part.status.type"running" | "complete" | "incomplete" | "requires-action"Lifecycle of the call backing the render.
useToolArgsStatus().propStatusPartial<Record<keyof TArgs, "streaming" | "complete">>Per top-level argument streaming state; called from inside the renderer.