Skip to content
Elements

Todo list

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

Todos
0/3 ยท rev 1
  • active
    Read the failing test

    Trace the converter's current behavior.

  • pending
    Fix the converter
  • cancelled
    Rewrite the fixture
  • pending
    Re-run the suite
fig. 01 ยท plays once, replay from the corner

Installation

Install manually
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, what was cancelled, 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 needs somewhere to write the plan, and the renderer reads it from 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({
      title: z.string().optional(),
      description: z.string().optional(),
      maxVisible: z.number().optional(),
      items: z.array(
        z.object({
          id: z.string(),
          text: z.string(),
          description: z.string().optional(),
          status: z.enum(["pending", "active", "done", "failed", "cancelled"]),
          reason: z.string().optional(),
        }),
      ),
    }),
    execute: async ({ items }) => ({ count: items.length }),
    render: ({ args }) => (
      <TodoList
        title={args.title}
        description={args.description}
        maxVisible={args.maxVisible}
        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>
    <div>
      <span>Todos</span>
      <p>{/* optional list description */}</p>
    </div>
    <span>{/* n/m, or n/m ยท rev r when revision is set */}</span>
  </div>
  <ul>
    <li data-status="pending | active | done | failed | cancelled">
      <span>{/* status icon: check, spinner, cross, dash, or empty box */}</span>
      <span>{/* the status as visually hidden text */}</span>
      <div>
        <span>{/* item text */}</span>
        <p>{/* optional item description or failure reason */}</p>
      </div>
    </li>
    {/* one row per item */}
  </ul>
  <button aria-expanded="false">Show N more</button>
</div>

Each row shows one of five status icons: a checked box for done, a spinning loader for active, a red cross for failed, a muted dash for cancelled, 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. Item descriptions appear below pending, active, and done items. A failed item's reason takes that place when present. Done and cancelled text is struck through in the muted foreground. Rows key off item.id; passing a new array with the same ids only restyles the existing rows, while a 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. A cancelled item has left the plan, so it counts toward neither: three items with two done and one cancelled read 2/2. With zero items the header still renders 0/0 and the list is empty, with no placeholder message. revision does not affect the count; it only appends ยท rev {revision} next to the ratio when you pass a number.

Examples

Item states

The five 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" },
    { id: "5", text: "Replace the old fixture", status: "cancelled" },
  ]}
/>

Fold a long list

maxVisible keeps the first rows in view, then keeps any later active or failed row visible as well. The button reveals the remaining rows in their original order:

<TodoList
  maxVisible={3}
  items={[
    { id: "1", text: "Read the failing test", status: "done" },
    { id: "2", text: "Reproduce locally", status: "done" },
    { id: "3", text: "Write the fix", status: "pending" },
    { id: "4", text: "Run typecheck", status: "active" },
    { id: "5", text: "Run unit tests", status: "failed", reason: "Timed out" },
    { id: "6", text: "Update the changelog", status: "pending" },
    { id: "7", text: "Publish", status: "pending" },
  ]}
/>

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 the task list fields below.
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".

Todo tool arguments

FieldTypeDescription
itemsTodoItem[]The rows to render in order.
titlestringOptional heading. The element falls back to "Todos".
descriptionstringOptional muted text below the heading.
maxVisiblenumberOptional count of initial rows to show before folding. Later active and failed rows stay visible.

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.