Skip to main content

Tutorial: AI Task Manager

In this tutorial, you’ll build a task manager where an AI assistant helps users break down projects into tasks, prioritize work, and track progress. Along the way, you’ll learn:
  • Multiple systems in one project
  • Human-in-the-loop approval flows
  • Streaming with artifacts
  • Connecting from a React frontend
The finished application has two systems: a Project that holds tasks and state, and a Planner agent that helps decompose work.

Setup

If you haven’t already, create a new project:
npx create-idyllic task-manager
cd task-manager
npm install
Add your API key to .dev.vars:
OPENAI_API_KEY=sk-...

Part 1: The Project System

A Project holds a list of tasks and provides methods to manipulate them. Create idyllic/project.ts:
import { System } from "idyllic";

interface Task {
  id: string;
  title: string;
  description: string;
  status: "todo" | "in-progress" | "done";
  priority: "low" | "medium" | "high";
  createdAt: number;
}

export default class Project extends System {
  name: string = "";
  description: string = "";
  tasks: Task[] = [];

  async init(name: string, description: string) {
    this.name = name;
    this.description = description;
    return { id: this.id, name, description };
  }

  async addTask(title: string, description: string, priority: Task["priority"] = "medium") {
    const task: Task = {
      id: crypto.randomUUID(),
      title,
      description,
      status: "todo",
      priority,
      createdAt: Date.now(),
    };
    this.tasks.push(task);
    return task;
  }

  async updateTask(taskId: string, updates: Partial<Task>) {
    const task = this.tasks.find(t => t.id === taskId);
    if (!task) throw new Error(`Task ${taskId} not found`);
    Object.assign(task, updates);
    return task;
  }

  async deleteTask(taskId: string) {
    const index = this.tasks.findIndex(t => t.id === taskId);
    if (index === -1) throw new Error(`Task ${taskId} not found`);
    this.tasks.splice(index, 1);
    return { deleted: taskId };
  }

  async getTasks(filter?: { status?: Task["status"]; priority?: Task["priority"] }) {
    let result = this.tasks;
    if (filter?.status) {
      result = result.filter(t => t.status === filter.status);
    }
    if (filter?.priority) {
      result = result.filter(t => t.priority === filter.priority);
    }
    return result;
  }

  async getStats() {
    return {
      total: this.tasks.length,
      todo: this.tasks.filter(t => t.status === "todo").length,
      inProgress: this.tasks.filter(t => t.status === "in-progress").length,
      done: this.tasks.filter(t => t.status === "done").length,
    };
  }
}
This is a plain TypeScript class that extends System. No AI yet — just state and methods. Start the dev server:
npx idyllic dev
Test it from a script:
// test-project.ts
import { create } from "@idyllic/client";
import type { Project } from "./idyllic/project";

async function main() {
  const project = await create<Project>("project");

  await project.init("Website Redesign", "Modernize the company website");

  await project.addTask("Audit current site", "Review all pages and note issues", "high");
  await project.addTask("Create wireframes", "Design new page layouts", "high");
  await project.addTask("Choose color palette", "Select brand colors", "medium");

  const stats = await project.getStats();
  console.log(stats);
  // { total: 3, todo: 3, inProgress: 0, done: 0 }

  const tasks = await project.getTasks({ priority: "high" });
  console.log(tasks.map(t => t.title));
  // ["Audit current site", "Create wireframes"]
}

main();
npx tsx test-project.ts
You now have a persistent project object. The state survives between script runs — reconnect with the same ID and your tasks are still there.

Part 2: The Planner Agent

Now add AI. Create idyllic/planner.ts — an agent that helps break down goals into tasks:
import { System } from "idyllic";
import { streamText } from "ai";
import { openai } from "@ai-sdk/openai";

interface SuggestedTask {
  title: string;
  description: string;
  priority: "low" | "medium" | "high";
}

export default class Planner extends System {
  async suggestTasks(goal: string, context?: string): Promise<SuggestedTask[]> {
    const prompt = `You are a project planning assistant. Given a goal, suggest 3-5 concrete tasks to accomplish it.

Goal: ${goal}
${context ? `Context: ${context}` : ""}

Respond with a JSON array of tasks. Each task has:
- title: short, action-oriented (e.g., "Research competitor pricing")
- description: 1-2 sentences of detail
- priority: "high", "medium", or "low"

Respond with ONLY the JSON array, no other text.`;

    // Create artifact to show thinking
    const thinking = this.addArtifact({
      type: "thinking",
      title: "Planning tasks...",
    });
    thinking.setStreaming(true);

    const result = await streamText({
      model: openai("gpt-4o"),
      prompt,
    });

    let response = "";
    for await (const chunk of result.textStream) {
      response += chunk;
      thinking.appendContent(chunk);
    }

    thinking.complete();

    try {
      return JSON.parse(response);
    } catch {
      throw new Error("Failed to parse task suggestions");
    }
  }

  async prioritize(tasks: Array<{ title: string; description: string }>) {
    const prompt = `You are a prioritization expert. Given these tasks, rank them by importance and urgency.

Tasks:
${tasks.map((t, i) => `${i + 1}. ${t.title}: ${t.description}`).join("\n")}

Respond with a JSON array of objects with:
- index: the original task number (1-based)
- priority: "high", "medium", or "low"
- reasoning: one sentence explaining why

Respond with ONLY the JSON array.`;

    const analysis = this.addArtifact({
      type: "analysis",
      title: "Analyzing priorities...",
    });
    analysis.setStreaming(true);

    const result = await streamText({
      model: openai("gpt-4o"),
      prompt,
    });

    let response = "";
    for await (const chunk of result.textStream) {
      response += chunk;
      analysis.appendContent(chunk);
    }

    analysis.complete();

    return JSON.parse(response);
  }
}
The Planner is a separate system. It doesn’t hold project state — it just provides AI capabilities. When it thinks, it streams into artifacts that sync to connected clients. Test it:
// test-planner.ts
import { create } from "@idyllic/client";
import type { Planner } from "./idyllic/planner";

async function main() {
  const planner = await create<Planner>("planner");

  // Watch history for streaming artifacts
  planner.subscribe((state) => {
    const latest = state.history.at(-1);
    if (latest?.type === "artifact" && latest.streaming) {
      process.stdout.write(latest.content);
    }
  });

  const suggestions = await planner.suggestTasks(
    "Launch a new product landing page",
    "We're a B2B SaaS company targeting enterprise customers"
  );

  console.log("\n\nSuggested tasks:");
  suggestions.forEach(s => {
    console.log(`- [${s.priority}] ${s.title}`);
  });
}

main();

Part 3: Human-in-the-Loop

Now combine them. Add a method to Project that uses the Planner but asks for human approval before adding tasks:
// Add to idyllic/project.ts

import { create } from "@idyllic/client";
import type { Planner } from "./planner";

export default class Project extends System {
  // ... existing code ...

  async planTasks(goal: string) {
    // Create a planner instance for this planning session
    const planner = await create<Planner>("planner");

    // Add status to history
    this.addArtifact({
      type: "status",
      title: "Planning",
      content: "Generating task suggestions...",
    });

    // Get AI suggestions
    const suggestions = await planner.suggestTasks(goal, this.description);

    // Show suggestions in history
    this.addArtifact({
      type: "suggestions",
      title: "Task Suggestions",
      content: JSON.stringify(suggestions, null, 2),
    });

    // Wait for user to approve/reject each task
    const approved: typeof suggestions = [];

    for (const suggestion of suggestions) {
      const accept = await this.confirm(
        `Add task: "${suggestion.title}" (${suggestion.priority} priority)?`
      );

      if (accept) {
        const task = await this.addTask(
          suggestion.title,
          suggestion.description,
          suggestion.priority
        );
        approved.push(suggestion);

        // Record in history
        this.addMessage(`Added task: ${suggestion.title}`, "assistant");
      } else {
        this.addMessage(`Skipped task: ${suggestion.title}`, "assistant");
      }
    }

    return {
      suggested: suggestions.length,
      added: approved.length,
      tasks: approved,
    };
  }
}
The this.confirm() call pauses execution. The runtime hibernates the Durable Object, persists state, and waits. When the user responds, execution resumes exactly where it left off. Test it:
// test-planning.ts
import { create } from "@idyllic/client";
import type { Project } from "./idyllic/project";
import * as readline from "readline";

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

function ask(question: string): Promise<boolean> {
  return new Promise((resolve) => {
    rl.question(question + " (y/n) ", (answer) => {
      resolve(answer.toLowerCase() === "y");
    });
  });
}

async function main() {
  const project = await create<Project>("project");
  await project.init("Mobile App", "Build a fitness tracking mobile app");

  // Handle confirmation requests
  project.onConfirm(async (prompt) => {
    return await ask(prompt);
  });

  // Watch history updates
  project.subscribe((state) => {
    const latest = state.history.at(-1);
    if (latest?.type === "message") {
      console.log(`[${latest.role}] ${latest.content}`);
    }
    if (latest?.type === "artifact" && latest.artifactType === "status") {
      console.log(`[Status] ${latest.content}`);
    }
  });

  // Start planning — this will prompt for each task
  const result = await project.planTasks("Build the core workout tracking feature");

  console.log(`\nAdded ${result.added} of ${result.suggested} suggested tasks`);

  const allTasks = await project.getTasks();
  console.log("\nAll tasks:");
  allTasks.forEach(t => console.log(`- ${t.title}`));

  rl.close();
}

main();
Run it and you’ll be prompted for each task suggestion. The AI generates ideas; you decide what actually gets added.

Part 4: React Frontend

Now build a UI. The @idyllic/react package provides hooks that handle WebSocket connections and state sync. Create app/page.tsx:
"use client";

import { useSystem, useCreate } from "@idyllic/react";
import type { Project } from "../idyllic/project";
import { useState } from "react";

export default function Home() {
  const [projectId, setProjectId] = useState<string | null>(null);

  if (!projectId) {
    return <CreateProject onCreated={setProjectId} />;
  }

  return <ProjectView projectId={projectId} />;
}

function CreateProject({ onCreated }: { onCreated: (id: string) => void }) {
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const createProject = useCreate<Project>("project");

  async function handleCreate() {
    const project = await createProject();
    await project.init(name, description);
    onCreated(project.id);
  }

  return (
    <div className="p-8 max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-4">New Project</h1>
      <input
        className="w-full p-2 border rounded mb-2"
        placeholder="Project name"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <textarea
        className="w-full p-2 border rounded mb-4"
        placeholder="Description"
        value={description}
        onChange={(e) => setDescription(e.target.value)}
      />
      <button
        className="w-full p-2 bg-blue-500 text-white rounded"
        onClick={handleCreate}
      >
        Create Project
      </button>
    </div>
  );
}

function ProjectView({ projectId }: { projectId: string }) {
  const { state, call, pendingConfirm, respond } = useSystem<Project>("project", projectId);
  const [goal, setGoal] = useState("");
  const [planning, setPlanning] = useState(false);

  // State syncs automatically — these reflect server state
  const tasks = state?.tasks ?? [];
  const name = state?.name ?? "Loading...";
  const history = state?.history ?? [];

  async function handlePlan() {
    setPlanning(true);
    await call("planTasks", goal);
    setGoal("");
    setPlanning(false);
  }

  async function handleToggleStatus(taskId: string, currentStatus: string) {
    const next = currentStatus === "done" ? "todo" : "done";
    await call("updateTask", taskId, { status: next });
  }

  return (
    <div className="p-8 max-w-2xl mx-auto">
      <h1 className="text-2xl font-bold mb-4">{name}</h1>

      {/* Planning input */}
      <div className="mb-6">
        <input
          className="w-full p-2 border rounded mb-2"
          placeholder="What do you want to accomplish?"
          value={goal}
          onChange={(e) => setGoal(e.target.value)}
          disabled={planning}
        />
        <button
          className="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
          onClick={handlePlan}
          disabled={planning || !goal}
        >
          {planning ? "Planning..." : "Plan Tasks"}
        </button>
      </div>

      {/* Confirmation dialog — appears when system calls this.confirm() */}
      {pendingConfirm && (
        <div className="mb-6 p-4 border-2 border-yellow-400 rounded bg-yellow-50">
          <p className="mb-2">{pendingConfirm.prompt}</p>
          <button
            className="px-4 py-2 bg-green-500 text-white rounded mr-2"
            onClick={() => respond(true)}
          >
            Yes
          </button>
          <button
            className="px-4 py-2 bg-red-500 text-white rounded"
            onClick={() => respond(false)}
          >
            No
          </button>
        </div>
      )}

      {/* History — shows artifacts and messages */}
      <div className="mb-6 space-y-2">
        {history.filter(h => h.type === "artifact" || h.type === "message").map((item) => (
          <div key={item.id} className="p-2 bg-gray-50 rounded text-sm">
            {item.type === "message" && (
              <span className={item.role === "assistant" ? "text-blue-600" : ""}>
                {item.content}
              </span>
            )}
            {item.type === "artifact" && (
              <div>
                <span className="font-medium">{item.title}</span>
                {item.streaming && <span className="text-gray-400"> (streaming...)</span>}
              </div>
            )}
          </div>
        ))}
      </div>

      {/* Task list */}
      <div className="space-y-2">
        {tasks.map((task) => (
          <div
            key={task.id}
            className={`p-3 border rounded flex items-center gap-3 ${
              task.status === "done" ? "bg-gray-100" : ""
            }`}
          >
            <input
              type="checkbox"
              checked={task.status === "done"}
              onChange={() => handleToggleStatus(task.id, task.status)}
            />
            <div className="flex-1">
              <div className={task.status === "done" ? "line-through" : ""}>
                {task.title}
              </div>
              <div className="text-sm text-gray-500">{task.description}</div>
            </div>
            <span
              className={`text-xs px-2 py-1 rounded ${
                task.priority === "high"
                  ? "bg-red-100 text-red-800"
                  : task.priority === "medium"
                  ? "bg-yellow-100 text-yellow-800"
                  : "bg-gray-100 text-gray-800"
              }`}
            >
              {task.priority}
            </span>
          </div>
        ))}
      </div>
    </div>
  );
}
Start the frontend:
npm run dev
Open http://localhost:3000. Create a project, type a goal, and watch:
  1. The AI generates task suggestions (artifacts stream)
  2. Confirmation dialogs appear one by one
  3. Approved tasks appear in the list
  4. History shows what happened
The useSystem hook handles everything: WebSocket connection, state sync, history updates, confirmation handling. The state object always reflects server state.

Part 5: Multiple Users

Because state lives on the server, multiple clients can connect to the same project. Open another browser tab with the same project ID — changes sync instantly. This happens automatically. The runtime broadcasts history updates to all connected clients. No additional code needed.

What You Built

In 30 minutes, you built:
  • Two systems — Project for data, Planner for AI
  • Persistent state — Tasks survive restarts, reconnections, deployments
  • Human-in-the-loop — AI suggests, humans approve
  • Streaming artifacts — See AI thinking in real-time
  • Real-time sync — Multiple clients see the same state
The code reads like plain TypeScript because it is. History handles sync. The runtime handles persistence. You write application logic.

Next Steps


FAQ

Why separate Project and Planner into different systems?

Separation of concerns. Project holds state and business logic. Planner provides AI capabilities. You could put everything in one class, but separating them makes each easier to test, evolve, and reuse.

What happens if the user closes the browser mid-confirmation?

The Durable Object hibernates with execution paused at this.confirm(). When they reconnect and load the project, the pending confirmation appears. They respond, execution resumes, and the workflow completes.

Can I use a different LLM provider?

Yes. Swap openai("gpt-4o") for anthropic("claude-3-opus") or any provider the AI SDK supports. The system code stays the same.

How do I add authentication?

Authentication happens in your frontend/API layer before connecting to Idyllic. Pass user IDs when creating projects, store the mapping in your database, and only allow connections from authorized users. Idyllic handles the stateful execution; you handle who’s allowed to access what.