Most AI frameworks give you one way to communicate with the backend: sendMessage. Your client sends a message, the server responds, and that’s the extent of your API surface. This works for chatbots but constrains everything else.Idyllic takes a different approach. When you decorate a method with @action(), that method becomes a typed RPC endpoint. Your React application calls it as if it were a regular async function. The framework routes the call over WebSocket to your server, executes the method, and returns the result. You define whatever operations your application needs—not just “send message.”
This design eliminates the ceremony of traditional API development. You don’t define route handlers, write fetch calls, or maintain separate type definitions. The framework extracts types from your method signatures and generates the client interface automatically.
The decorator transforms a method into an RPC endpoint through several coordinated steps:
Registers the method in a callable registry so the runtime knows which methods can receive external calls
Generates a client stub with a TypeScript signature matching your server method
Sets up RPC handling that routes WebSocket messages to the correct method
Broadcasts state changes to all connected clients after execution completes
Preserves this context so your method can access fields and call other methods
Methods without @action() remain internal, invisible to clients. This lets you organize code with public actions for external requests and private helpers for implementation:
Idyllic extracts types from your action methods and propagates them to client code. The CLI analyzes your system classes, extracts signatures, and writes TypeScript interfaces. Your editor provides autocomplete, and TypeScript catches errors before runtime:
// Serverexport default class Calculator extends AgenticSystem { @action() async add(a: number, b: number): Promise<number> { return a + b; } @action() async divide(a: number, b: number): Promise<number> { if (b === 0) throw new Error("Division by zero"); return a / b; }}
// Client: full autocomplete, full type checkingconst { add, divide } = useSystem<Calculator>();const sum = await add(2, 3); // sum is inferred as numberawait add("2", 3); // Type error: string is not numberawait nonexistent(); // Type error: property doesn't exist
Arguments and return values must be JSON-serializable since they travel over WebSocket. Functions, class instances, and circular references cause runtime errors.
Actions return a single value when complete. They don’t stream their return value incrementally. For content that arrives progressively, use a stream<T> field instead.The pattern: an action initiates work and returns metadata; a stream field delivers updates:
Keep actions focused. Each action should do one coherent thing. Split compound actions into focused pieces clients can compose.Reset streams at the start. Clear previous content before producing new output:
@action()async generate(prompt: string): Promise<void> { this.output.reset(); // Clear previous content for await (const chunk of ai.stream(prompt)) { this.output.append(chunk); } this.output.complete();}
Return metadata, not content. When content streams to state, return useful metadata instead of duplicating data:
No. Durable Objects process one request at a time. Simultaneous calls queue and execute sequentially. This prevents race conditions but means long-running actions block other calls.