Skip to main content

Systems

A System is a TypeScript class that becomes a virtual object — a persistent, networked instance that feels like writing a local script but lives in the cloud with state that survives, history that streams, and methods you call from anywhere.

The Mental Model

Think of a System as a virtual object — a class instance that never dies:
// idyllic/counter.ts
export default class Counter {
  count: number = 0;

  async increment(): Promise<number> {
    this.count++;
    return this.count;
  }

  async decrement(): Promise<number> {
    this.count--;
    return this.count;
  }

  async getCount(): Promise<number> {
    return this.count;
  }
}
From anywhere — a script, a React app, a CLI tool — you connect to this object and call its methods:
import { counter } from "./idyllic/_generated/api";
import { createClient } from "@idyllic/client";

const idyllic = createClient();

// Create a new instance
const instance = await idyllic.create(counter);
console.log(instance.id);  // "ctr_a1b2c3d4"

await instance.call(counter.increment);  // 1
await instance.call(counter.increment);  // 2
await instance.call(counter.getCount);   // 2

// Later, from anywhere
const same = await idyllic.connect(counter, instance.id);
await same.call(counter.getCount);       // 2 — state persisted
The count property persists. Close your terminal, come back tomorrow, reconnect — it’s still there. You didn’t configure a database. You didn’t write save/load code. State just persists because that’s what Idyllic does.

Defining a System

A System is any class that:
  1. Has a default export — The CLI discovers it by scanning for default exports
  2. Lives in idyllic/ — Convention. The filename becomes the system name.
  3. Has async methods — Methods become callable RPC endpoints
// idyllic/assistant.ts → system name is "assistant"
export default class Assistant {
  messages: Message[] = [];

  async chat(content: string): Promise<string> {
    this.addMessage(content, "user");
    // ... generate response ...
  }
}
// idyllic/project.ts → system name is "project"
export default class Project {
  name: string = "";
  tasks: Task[] = [];

  async init(name: string): Promise<void> {
    this.name = name;
  }

  async addTask(title: string): Promise<Task> {
    // ...
  }
}
Multiple systems, one project. Each becomes its own type of persistent object.

State

Properties on your class are state. State persists automatically.
export default class Game {
  board: string[][] = [
    ["", "", ""],
    ["", "", ""],
    ["", "", ""],
  ];
  currentPlayer: "X" | "O" = "X";
  moves: number = 0;

  async makeMove(row: number, col: number): Promise<{ board: string[][]; nextPlayer: string }> {
    if (this.board[row][col] !== "") {
      throw new Error("Cell already occupied");
    }

    this.board[row][col] = this.currentPlayer;
    this.currentPlayer = this.currentPlayer === "X" ? "O" : "X";
    this.moves++;

    return { board: this.board, nextPlayer: this.currentPlayer };
  }
}
When makeMove modifies this.board, the change persists. Reconnect tomorrow and the board is as you left it. All connected clients see the update immediately.

History

Systems have a history tree for streaming structured content to clients.

Adding Messages

export default class Assistant {
  async chat(content: string): Promise<string> {
    // Add user message to history
    this.addMessage(content, "user");

    // Generate and add assistant response
    const response = await this.generateResponse();
    this.addMessage(response, "assistant");

    return response;
  }
}
Messages sync to all connected clients automatically. The frontend renders them. Context builds from them.

Streaming with Artifacts

Artifacts are typed content that can stream:
export default class Writer {
  async generate(prompt: string): Promise<string> {
    // Create artifact — appears in clients immediately
    const doc = this.addArtifact({
      type: "document",
      title: "Generated Content",
    });
    doc.setStreaming(true);

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

    // Stream content — updates sync in real-time
    for await (const chunk of result.textStream) {
      doc.appendContent(chunk);
    }

    doc.complete();
    return doc.data.content;
  }
}
The artifact appears the moment addArtifact is called. As appendContent runs, content streams to all connected clients. No manual event emission — history sync handles it.

Running Subagents

export default class Editor {
  static agents = {
    writer: { instructions: "You write clear, engaging content." },
    reviewer: { instructions: "You review content for clarity and accuracy." },
  };

  async createArticle(topic: string): Promise<{ articleId: string; feedback: string }> {
    // Create artifact to stream into
    const article = this.addArtifact({
      type: "document",
      title: topic,
    });

    // Run writer agent, streaming to artifact
    await this.runAgent("writer", `Write about: ${topic}`, {
      streamTo: article,
    });

    // Run reviewer
    const feedback = await this.runAgent("reviewer", article.data.content);

    return { articleId: article.id, feedback };
  }
}
Subagents run with their own context. Their output can stream directly into artifacts.

Pausing for Input

Use this.confirm() and this.ask() to pause execution and wait for human input:
export default class Publisher {
  async publish(draft: string): Promise<{ status: string; feedback?: string }> {
    // Execution pauses here — could be seconds, hours, days
    const approved = await this.confirm("Publish this draft?");

    if (!approved) {
      const feedback = await this.ask("What should change?");
      return { status: "rejected", feedback };
    }

    // User approved — continue
    await this.deploy(draft);
    return { status: "published" };
  }
}
The runtime hibernates the Durable Object while waiting. No compute consumed. When the user responds, execution resumes exactly where it paused.

Calling Other Systems

Systems can create and connect to other systems:
import { planner } from "./idyllic/_generated/api";

export default class Project {
  async planWork(goal: string): Promise<Task[]> {
    // Create a Planner instance for this task
    const plannerInstance = await idyllic.create(planner);

    // Call its method
    const suggestions = await plannerInstance.call(planner.suggestTasks, goal);

    // Use the results
    for (const task of suggestions) {
      await this.addTask(task.title, task.description);
    }

    return suggestions;
  }
}
This enables composition. Complex systems delegate to specialized ones. A Project manages state; a Planner provides AI capabilities; a Reviewer checks quality. Each is testable and deployable independently.

Building Context

The history tree contains everything that happened. To call an LLM, you build context from it:
export default class Assistant {
  async chat(content: string): Promise<string> {
    this.addMessage(content, "user");

    // Build context from history
    const context = this.buildContext();

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

    // ... stream response
  }
}
The default buildContext() returns relevant history. Override it to customize:
protected buildContext(query?: string) {
  // Custom strategy — maybe only recent messages,
  // or filter by relevance, or include specific artifacts
  return this.history.query({
    types: ["message"],
    limit: 20,
  });
}

Environment Variables

Access secrets via this.env:
export default class Assistant {
  async chat(content: string): Promise<string> {
    const apiKey = this.env.OPENAI_API_KEY;
    // ...
  }
}
Set them in .dev.vars for local development:
OPENAI_API_KEY=sk-...
DATABASE_URL=postgres://...

The Generated API

When you run npx idyllic dev, the CLI scans your system and generates typed function references:
// idyllic/_generated/api.ts (auto-generated)
import { fn, system } from "@idyllic/core";

export const assistant = system("assistant", {
  chat: fn<[content: string], string>("assistant", "chat"),
  summarize: fn<[], string>("assistant", "summarize"),
  clear: fn<[], void>("assistant", "clear"),
});
This gives you full type safety when calling methods remotely. Arguments and return types are preserved.

FAQ

What happens to state when I redeploy?

State persists across deployments. Your Durable Objects keep their data. New code runs against existing state. This is usually what you want — users keep their conversations, projects keep their tasks. If you need to migrate state, handle it in your code. Check for old formats, transform as needed.

Can one system call methods on another?

Yes. Use idyllic.create() or idyllic.connect() inside your system methods. Systems are networked objects — they can call each other.

How do I test a system?

For unit tests, instantiate your class directly:
import Assistant from "./idyllic/assistant";

test("chat works", async () => {
  const assistant = new Assistant();
  // Mock the injected dependencies
  assistant.addMessage = jest.fn();
  assistant.addArtifact = jest.fn(() => ({ setStreaming: jest.fn(), appendContent: jest.fn(), complete: jest.fn(), data: { content: "test" } }));

  const result = await assistant.chat("Hello");
  expect(assistant.addMessage).toHaveBeenCalledWith("Hello", "user");
});
For integration tests, run npx idyllic dev and test against the real runtime.

What’s the difference between create and connect?

create() makes a new instance with a generated ID. connect() attaches to an existing instance by ID. Use create when starting fresh, connect when resuming.

Can multiple clients connect to the same instance?

Yes. All connected clients see the same state and history. Changes sync automatically. This enables collaborative features without additional code.

What’s the difference between state and history?

State is class properties — this.count, this.tasks, etc. It persists and syncs to clients automatically. History is the tree of events — messages, artifacts, agent runs. It also syncs to all connected clients and drives the UI. Both sync. Use state for structured data. Use history for streaming content and conversation flow.

Next: History

Understanding the structured record of system activity