Skip to content

Task Assistant Example

A full-stack task management app with an AI assistant. Demonstrates tools with state, real-time channels, and React integration.

What You'll Build

  • An agent that manages todo lists
  • Tools that render context and sync in real-time
  • A React frontend with live updates
  • Express backend with SSE channels

Project Structure

task-assistant/
├── backend/
│   ├── src/
│   │   ├── agents/
│   │   │   └── task-assistant.tsx
│   │   ├── tools/
│   │   │   └── todo-tool.tsx
│   │   ├── channels/
│   │   │   └── todo.channel.ts
│   │   └── index.ts
│   └── package.json
└── frontend/
    ├── src/
    │   ├── components/
    │   │   ├── Chat.tsx
    │   │   └── TodoList.tsx
    │   └── App.tsx
    └── package.json

Backend

Agent Definition

tsx
// backend/src/agents/task-assistant.tsx
import {
  Component,
  Model,
  System,
  Timeline,
  Message,
  Context,
  comState,
} from "aidk";
import { aisdk } from "aidk-ai-sdk";
import { openai } from "@ai-sdk/openai";
import { TodoTool } from "../tools/todo-tool";
import type { COMTimelineEntry } from "aidk";

export class TaskAssistant extends Component {
  // Signal-based state that accumulates timeline entries
  private timeline = comState<COMTimelineEntry[]>("timeline", []);

  onTickStart(com, state) {
    // Accumulate entries from model responses
    if (state.current?.timeline) {
      this.timeline.update((t) => [...t, ...state.current.timeline]);
    }
  }

  render(com, state) {
    const ctx = Context.get();

    return (
      <>
        <Model model={aisdk({ model: openai("gpt-5.2") })} />

        <System>
          You are a task assistant for {ctx.user.name}. Help them manage their
          todo list. You can: - Add new tasks - Mark tasks complete - List all
          tasks Be concise. Confirm actions you take.
        </System>

        <TodoTool />

        <Timeline>
          {this.timeline().map((entry) => (
            <Message key={entry.id} {...entry.message} />
          ))}
        </Timeline>
      </>
    );
  }
}

Todo Tool

tsx
// backend/src/tools/todo-tool.tsx
import { createTool, Context, Grounding, List, ListItem } from "aidk";
import { z } from "zod";
import { TodoService } from "../services/todo.service";
import { todoChannel } from "../channels/todo.channel";

// Tool with full lifecycle - handles execution AND renders context
export const TodoTool = createTool({
  name: "todo",
  description: "Manage the todo list. Actions: add, complete, list",
  input: z.object({
    action: z.enum(["add", "complete", "list"]),
    task: z.string().optional().describe("Task text for add"),
    taskId: z.string().optional().describe("Task ID for complete"),
  }),

  // Load initial state when tool mounts
  async onMount(com) {
    const ctx = Context.get();
    const tasks = await TodoService.list(ctx.user.id);
    com.setState("tasks", tasks);
  },

  // Execute when the model calls the tool
  handler: async (input) => {
    const ctx = Context.get();
    const userId = ctx.user.id;

    switch (input.action) {
      case "add": {
        const task = await TodoService.add(userId, input.task!);

        // Broadcast via channel router
        todoChannel
          .publisher()
          .to(userId)
          .broadcast({ type: "task_added", payload: task });

        return [{ type: "text", text: `Added task: ${task.text}` }];
      }

      case "complete": {
        const task = await TodoService.complete(userId, input.taskId!);

        todoChannel
          .publisher()
          .to(userId)
          .broadcast({ type: "task_completed", payload: { id: task.id } });

        return [{ type: "text", text: `Completed: ${task.text}` }];
      }

      case "list": {
        const tasks = await TodoService.list(userId);

        if (tasks.length === 0) {
          return [{ type: "text", text: "No tasks yet." }];
        }

        const list = tasks
          .map((t) => `${t.done ? "✓" : "○"} ${t.text}`)
          .join("\n");

        return [{ type: "text", text: list }];
      }
    }
  },

  // Render current state as context for the model
  render(com) {
    const tasks = com.getState("tasks") || [];

    if (tasks.length === 0) {
      return null;
    }

    return (
      <Grounding title="Current Tasks">
        <List task>
          {tasks.map((t) => (
            <ListItem key={t.id} checked={t.done}>{t.text}</ListItem>
          ))}
        </List>
      </Grounding>
    );
  },
});

Channel Router

tsx
// backend/src/channels/todo.channel.ts
import { ChannelRouter } from "aidk";
import { TodoService } from "../services/todo.service";

export const todoChannel = new ChannelRouter<{
  userId: string;
}>("todo", {
  scope: { user: "userId" },
}).on("sync", async (event, ctx) => {
  const tasks = await TodoService.list(ctx.userId);
  return { type: "sync_response", payload: { tasks } };
});

Server Setup

tsx
// backend/src/index.ts
import express from "express";
import cors from "cors";
import { createEngine } from "aidk";
import { createExpressMiddleware } from "aidk-express";
import { StreamableHTTPTransport } from "aidk/channels/transports";
import { TaskAssistant } from "./agents/task-assistant";
import { todoChannel } from "./channels/todo.channel";

const app = express();
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());

const transport = new StreamableHTTPTransport();

const engine = createEngine({
  channels: {
    transport,
    routers: [todoChannel],
  },
});

app.use(
  "/api/agent",
  createExpressMiddleware({
    engine,
    agent: TaskAssistant,
    context: (req) => ({
      user: {
        id: (req.headers["x-user-id"] as string) || "default",
        name: (req.headers["x-user-name"] as string) || "User",
      },
    }),
  }),
);

app.listen(3001, () => {
  console.log("Backend running on http://localhost:3001");
});

Frontend

App Setup

tsx
// frontend/src/App.tsx
import { EngineProvider } from "aidk-react";
import { EngineClient } from "aidk-client";
import { Chat } from "./components/Chat";
import { TodoList } from "./components/TodoList";

const client = new EngineClient({
  baseUrl: "http://localhost:3001/api/agent",
  headers: () => ({
    "x-user-id": "user-1",
    "x-user-name": "Alice",
  }),
});

function App() {
  return (
    <EngineProvider client={client}>
      <div className="app">
        <aside>
          <TodoList />
        </aside>
        <main>
          <Chat />
        </main>
      </div>
    </EngineProvider>
  );
}

export default App;

Chat Component

tsx
// frontend/src/components/Chat.tsx
import { useState } from "react";
import {
  useExecution,
  useStreamingText,
  ContentBlockRenderer,
} from "aidk-react";

export function Chat() {
  const { messages, isStreaming, sendMessage } = useExecution();
  const streamingText = useStreamingText();
  const [input, setInput] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim() || isStreaming) return;

    const message = input;
    setInput("");
    await sendMessage(message);
  };

  return (
    <div className="chat">
      <div className="messages">
        {messages.map((msg) => (
          <div key={msg.id} className={`message ${msg.role}`}>
            {msg.content.map((block, i) => (
              <ContentBlockRenderer key={i} block={block} />
            ))}
          </div>
        ))}

        {isStreaming && streamingText && (
          <div className="message assistant streaming">
            {streamingText}
            <span className="cursor" />
          </div>
        )}
      </div>

      <form onSubmit={handleSubmit} className="input-form">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Ask about your tasks..."
          disabled={isStreaming}
        />
        <button type="submit" disabled={isStreaming || !input.trim()}>
          Send
        </button>
      </form>
    </div>
  );
}

TodoList Component

tsx
// frontend/src/components/TodoList.tsx
import { useState, useEffect } from "react";
import { useExecution } from "aidk-react";

type Task = {
  id: string;
  text: string;
  done: boolean;
};

export function TodoList() {
  const [tasks, setTasks] = useState<Task[]>([]);
  const { subscribe } = useExecution();

  useEffect(() => {
    // Subscribe to channel events from the execution stream
    const unsubscribe = subscribe((event) => {
      if (event.type === "channel" && event.channel === "todo") {
        const { type, payload } = event.payload;

        if (type === "task_added") {
          setTasks((prev) => [...prev, payload as Task]);
        }

        if (type === "task_completed") {
          setTasks((prev) =>
            prev.map((t) => (t.id === payload.id ? { ...t, done: true } : t)),
          );
        }
      }
    });

    return unsubscribe;
  }, [subscribe]);

  const pending = tasks.filter((t) => !t.done);
  const completed = tasks.filter((t) => t.done);

  return (
    <div className="todo-list">
      <h2>Tasks</h2>

      {pending.length > 0 && (
        <section>
          <h3>To Do</h3>
          <ul>
            {pending.map((task) => (
              <li key={task.id}>
                <span className="checkbox">○</span>
                {task.text}
              </li>
            ))}
          </ul>
        </section>
      )}

      {completed.length > 0 && (
        <section>
          <h3>Done</h3>
          <ul className="completed">
            {completed.map((task) => (
              <li key={task.id}>
                <span className="checkbox">✓</span>
                {task.text}
              </li>
            ))}
          </ul>
        </section>
      )}

      {tasks.length === 0 && (
        <p className="empty">No tasks yet. Ask the assistant to add some!</p>
      )}
    </div>
  );
}

Running the Example

  1. Start the backend:
bash
cd backend
npm install
npm run dev
  1. Start the frontend:
bash
cd frontend
npm install
npm run dev
  1. Try it out:

Open http://localhost:5173 and chat with the assistant:

  • "Add a task to buy groceries"
  • "Add another task to call mom"
  • "What's on my list?"
  • "Mark the groceries task as done"

Watch the sidebar update in real-time as tasks are added and completed.

Key Concepts Demonstrated

Tools with Full Lifecycle

The TodoTool uses createTool with onMount and render hooks. It loads initial state, handles execution, and renders context - all in one definition. Tools created with createTool are full components when used in JSX.

Real-Time Channels

When the tool handler modifies data, it broadcasts via the ChannelRouter. The React frontend subscribes to execution events and updates immediately.

COM State

Tools use com.setState()/com.getState() to manage state that persists across ticks. For class components, you can use comState() signals instead.

User Context

The backend extracts user info from headers and makes it available via Context.get(). Both the agent and tools can access it.

Next Steps

  • Add task priorities and due dates
  • Add multiple lists per user
  • Add task sharing between users
  • Persist data to a real database

See the full source in the example directory.

Released under the MIT License.