Skip to main content

AgenticSystem

In Idyllic, your backend logic lives in a TypeScript class that extends AgenticSystem. You define your application’s state as class properties decorated with @field and implement its behavior as methods decorated with @action(). The Idyllic runtime takes care of persisting your state, streaming updates to connected clients, and keeping everything synchronized across connections. This design means you write straightforward object-oriented code rather than wiring together API routes, database queries, and WebSocket handlers. The framework handles the infrastructure concerns so you can focus on your application logic.

The System is the Actor

Each AgenticSystem class runs as a Cloudflare Durable Object—a single-threaded, stateful actor that persists indefinitely. This is the unit of isolation and persistence in Idyllic. When you deploy your system, you’re deploying an actor that can be instantiated, addressed by ID, and will maintain its state across restarts and deployments. Within your system, you may have multiple “agents”—researcher objects, writer objects, employee objects. But these agents are not separate actors. They are properties of your class, sharing the same execution context and memory. The coordination between agents happens through regular method calls, not through message passing between isolated processes. This distinction matters for how you think about your code:
  • The Durable Object is the actor. It has an ID, maintains state, handles requests one at a time.
  • Agents are objects inside the actor. They share state, can call each other’s methods, coordinate through code.
  • Coordination is plain TypeScript. Sequential calls, conditional branches, loops—no special primitives needed.
import { AgenticSystem, field, action, stream } from 'idyllic';

export default class Assistant extends AgenticSystem {
  @field messages: Message[] = [];
  @field response = stream<string>('');

  @action()
  async chat(content: string) {
    this.messages.push({ role: 'user', content });
    // Generate and stream response...
  }
}

Class Structure

The AgenticSystem base class provides properties for accessing instance metadata and connections, lifecycle hooks for responding to events, and built-in methods for communicating with clients and scheduling work.
class AgenticSystem {
  // Properties
  readonly id: string;
  readonly name: string;
  readonly connections: Connection[];

  // Lifecycle (override in subclass)
  onCreate(): void | Promise<void>;
  onConnect(connection: Connection): void;
  onDisconnect(connection: Connection): void;
  onDestroy(): void | Promise<void>;

  // Built-in methods
  protected broadcast(message: any): void;
  protected broadcastTo(connectionIds: string[], message: any): void;
  protected schedule(when: Date | number | string, method: string, payload?: any): Schedule;
  protected cancelSchedule(schedule: Schedule): void;
  protected sql: SqlTaggedTemplate;
}

Properties

id

Every system instance receives a unique identifier when it’s created. This ID remains stable across server restarts, hibernation cycles, and deployments, making it suitable for use in URLs, database references, or anywhere you need to identify a specific instance.
console.log(this.id); // "sys_a1b2c3d4e5f6"

name

The name property contains the system type name, which Idyllic derives from your class name or filename. This is useful for logging or building generic utilities that work with multiple system types.

connections

The connections array contains all clients currently connected to this system instance via WebSocket. You can iterate over it to access connection metadata or count active users.
console.log(this.connections.length); // 3 clients connected

for (const conn of this.connections) {
  console.log(conn.id);        // Connection ID
  console.log(conn.metadata);  // Custom metadata from connect()
}

Defining State

State properties are marked with the @field decorator. Idyllic tracks changes to these properties and handles persistence and synchronization automatically.
export default class MySystem extends AgenticSystem {
  // Primitives
  @field count = 0;
  @field name = '';
  @field active = false;

  // Arrays and objects
  @field items: Item[] = [];
  @field config = { theme: 'light' as 'light' | 'dark' };

  // Streaming values
  @field response = stream<string>('');
}
When you assign to a @field property, Idyllic detects the change and immediately synchronizes it to all connected clients. There’s no explicit save or publish step required.
this.count = 10;           // Syncs immediately
this.items.push(newItem);  // Syncs immediately

Streaming Values

Some values need to update incrementally rather than all at once. When streaming a response from a language model, you want clients to see each chunk as it arrives rather than waiting for the complete response. Wrapping a property with stream<T>() enables this incremental update pattern.
@field response = stream<string>('');

@action()
async generate() {
  for await (const chunk of ai.stream(prompt)) {
    this.response.append(chunk);  // Streams to clients
  }
  this.response.complete();
}
Streaming values expose three methods: append() adds content incrementally (strings concatenate, objects merge), set() replaces the entire value, and complete() signals that streaming is finished. Clients receive only the incremental changes rather than the full value on each update.

Supported Types

Idyllic can persist and synchronize most JavaScript data types. Primitives (strings, numbers, booleans, null), plain objects, and arrays work as expected. The framework also supports Map and Set by serializing them as entries and arrays. Functions and class instances cannot be serialized and are not supported. Streaming values synchronize in real-time, though only the current value persists across restarts.

Nested State

State can be arbitrarily nested. Idyllic tracks changes at any depth and synchronizes them appropriately.
@field user = {
  name: '',
  preferences: {
    theme: 'light' as 'light' | 'dark',
    notifications: true,
  },
};

// Update nested values
this.user.preferences.theme = 'dark';  // Syncs correctly

Lifecycle Methods

Override these methods in your subclass to respond to events in your system’s lifecycle.

onCreate()

Called once when an instance is first created—not when it wakes from hibernation or restarts after deployment. Use it for one-time initialization: setting initial state that depends on external data, loading configuration, or setting up integrations.
async onCreate() {
  this.createdAt = Date.now();
  this.config = await fetchInitialConfig();
}

onConnect(connection)

Called each time a client establishes a WebSocket connection. Use it to track active users, send welcome messages, or notify other clients.
onConnect(connection: Connection) {
  this.activeUsers.push(connection.id);
  this.broadcast({ type: 'user-joined', userId: connection.id });
}
The connection object provides a unique id and any custom metadata the client passed when connecting.

onDisconnect(connection)

Called when a client’s WebSocket connection closes, whether from navigation, network interruption, or explicit disconnect.
onDisconnect(connection: Connection) {
  this.activeUsers = this.activeUsers.filter(id => id !== connection.id);
  this.broadcast({ type: 'user-left', userId: connection.id });
}

onDestroy()

Called when an instance is being permanently deleted. Use it to clean up external resources or cancel subscriptions.

Built-in Methods

broadcast(message)

Send a message to every client currently connected to this instance. Use this for notifications or events that all clients should receive.
this.broadcast({ type: 'notification', text: 'Processing complete' });

broadcastTo(connectionIds, message)

Send a message to specific clients by their connection IDs. Useful for targeted notifications like admin-only alerts.
this.broadcastTo([adminConnection.id], { type: 'admin-alert', text: 'New signup' });

schedule(when, method, payload?)

Schedule a method to run at a future time. You can specify timing as milliseconds (delay), a Date object, or a cron expression for recurring execution.
// Run in 60 seconds
this.schedule(60_000, 'sendReminder', { userId: '123' });

// Run at a specific time
this.schedule(new Date('2024-12-25'), 'sendHolidayGreeting');

// Run daily at 9am
this.schedule('0 9 * * *', 'dailyDigest');
Between scheduled executions, the Durable Object hibernates and consumes no compute resources. This makes scheduled tasks cost-effective for background work like sending reminders, processing batches, or generating periodic reports.

cancelSchedule(schedule)

Cancel a previously scheduled execution before it runs.
const scheduled = this.schedule(60_000, 'sendReminder');
this.cancelSchedule(scheduled);  // Changed our mind

sql

For advanced use cases, you can execute raw SQL queries against the instance’s SQLite database. This is an escape hatch for complex queries or migrations. Use sparingly—the @field system handles persistence automatically for most applications.
const results = this.sql`SELECT * FROM custom_table WHERE user_id = ${userId}`;

Persistence

Idyllic automatically persists @field properties to SQLite storage. You don’t write database code, define schemas, or call save methods. When you modify a field, the change persists. This persistence survives server restarts, hibernation, deployments, and infrastructure failures. Cloudflare replicates data automatically.

What Persists

Everything marked with @field that can be serialized to JSON persists automatically.

What Doesn’t Persist

Properties without @field do not persist. They reset to initial values when the instance restarts or wakes from hibernation. Use these for caches or temporary computation.
export default class MySystem extends AgenticSystem {
  @field count = 0;      // Persists

  cache = new Map();     // Does NOT persist
  pending = null;        // Does NOT persist
}

Persistence Guarantees

Idyllic provides strong guarantees: all state changes within a method succeed or fail together (transactional), reads always reflect recent writes (consistency), changes survive failures after method completion (durability), and only one method executes at a time (no race conditions).

Environment Variables

Access secrets and configuration through this.env. Idyllic injects environment variables automatically.
@action()
async chat(content: string) {
  const client = new OpenAI({ apiKey: this.env.OPENAI_API_KEY });
  // ...
}
For local development, add secrets to .dev.vars (gitignored by default):
OPENAI_API_KEY=sk-...

FAQ

What happens when I redeploy?

State persists across deployments. Your new code runs against existing state. If you change state structure, handle migration at the start of methods:
@action()
async chat(content: string) {
  if (!this.version) {
    this.messages = this.oldMessages ?? [];
    this.version = 2;
  }
  // Continue...
}

Can I have multiple system types?

Yes. Each file in your systems/ directory that exports a default class becomes a system type. Clients specify which type to connect to.

Can methods run concurrently?

No. Each Durable Object processes one request at a time, preventing race conditions. Long-running methods block other calls. Use Promise.all within a method for parallel async operations, or delegate to separate system instances.

How do I share state between instances?

Each instance has isolated state. For shared data: create a “coordinator” system that holds shared state, use external storage like a database, or have instances call methods on each other using their IDs.

Next: Streaming

The stream<T> primitive for real-time updates