Skip to main content

Actions

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.”
import { AgenticSystem, field, action, stream } from 'idyllic';

export default class Counter extends AgenticSystem {
  @field count = 0;
  @field result = stream<string>('');

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

  @action()
  async analyze(topic: string): Promise<void> {
    // Custom operation, not "chat"
  }
}
From React, you call these actions through the typed object that useSystem provides:
const { increment, analyze } = useSystem<Counter>();
const count = await increment();  // Typed as number
await analyze('climate change');   // Your custom operation
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.

What @action() Does

The decorator transforms a method into an RPC endpoint through several coordinated steps:
  1. Registers the method in a callable registry so the runtime knows which methods can receive external calls
  2. Generates a client stub with a TypeScript signature matching your server method
  3. Sets up RPC handling that routes WebSocket messages to the correct method
  4. Broadcasts state changes to all connected clients after execution completes
  5. 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:
export default class Analyzer extends AgenticSystem {
  @action()
  async analyze(input: string): Promise<AnalysisResult> {
    const cleaned = this.preprocess(input);      // Internal
    const result = await this.runModel(cleaned); // Internal
    return this.format(result);                  // Internal
  }

  private preprocess(input: string): string { /* ... */ }
  private async runModel(input: string): Promise<RawResult> { /* ... */ }
  private format(raw: RawResult): AnalysisResult { /* ... */ }
}

Type Safety

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:
// Server
export 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 checking
const { add, divide } = useSystem<Calculator>();

const sum = await add(2, 3);      // sum is inferred as number
await add("2", 3);                // Type error: string is not number
await 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.

Error Handling

When an action throws, the client’s promise rejects with the error:
// Server
@action()
async validateInput(input: string): Promise<void> {
  if (input.length < 3) {
    throw new Error("Input too short");
  }
}

// Client
try {
  await validateInput("ab");
} catch (e) {
  console.error(e.message);  // "Input too short"
}
For errors clients need to handle programmatically, use ActionError with structured codes:
import { ActionError } from 'idyllic';

@action()
async createUser(email: string): Promise<User> {
  if (!email.includes("@")) {
    throw new ActionError("VALIDATION_FAILED", "Invalid email format", {
      field: "email",
    });
  }

  if (await this.emailExists(email)) {
    throw new ActionError("DUPLICATE", "Email already registered");
  }

  return this.insertUser(email);
}

// Client
try {
  await createUser("invalid");
} catch (e) {
  if (e.code === "VALIDATION_FAILED") {
    showFieldError(e.details.field, e.message);
  } else if (e.code === "DUPLICATE") {
    showError("This email is already in use");
  }
}
Error codes let clients branch on failure cases without parsing strings.

Actions and Streaming

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:
export default class Writer extends AgenticSystem {
  @field response = stream<string>('');
  @field tokenCount = 0;

  @action()
  async generate(prompt: string): Promise<{ tokens: number }> {
    this.response.reset();

    for await (const chunk of ai.stream(prompt)) {
      this.response.append(chunk);
      this.tokenCount++;
    }

    this.response.complete();
    return { tokens: this.tokenCount };
  }
}
function Writer() {
  const { response, generate } = useSystem<Writer>();

  const handleSubmit = async (prompt: string) => {
    const result = await generate(prompt);
    console.log(`Used ${result.tokens} tokens`);
  };

  return <div>{response.current}</div>;
}
Actions perform operations. State reflects current status. Clients call actions to initiate work and observe state for progress.

State Updates

When an action modifies fields, changes persist and sync to all connected clients automatically:
export default class TaskManager extends AgenticSystem {
  @field tasks: Task[] = [];
  @field lastUpdated: Date | null = null;

  @action()
  async addTask(title: string): Promise<Task> {
    const task = { id: crypto.randomUUID(), title, completed: false };
    this.tasks.push(task);
    this.lastUpdated = new Date();
    return task;
  }

  @action()
  async completeTask(id: string): Promise<void> {
    const task = this.tasks.find(t => t.id === id);
    if (!task) throw new Error("Task not found");
    task.completed = true;
    this.lastUpdated = new Date();
  }
}
When any client calls these actions, all connected clients receive updated state immediately.

Constraints

Actions use RPC over WebSocket, which imposes constraints:
  • Must be async or return a Promise (RPC is inherently asynchronous)
  • Arguments and returns must be JSON-serializable (no functions, class instances, circular refs)
  • No overloaded signatures (use optional parameters or union types)
  • Only on methods, not getters/setters
  • Only on classes extending AgenticSystem

Best Practices

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:
@action()
async search(query: string): Promise<{ count: number; took: number }> {
  const start = Date.now();
  const results = await this.performSearch(query);
  this.results = results;
  return { count: results.length, took: Date.now() - start };
}

FAQ

Can actions call other actions?

Yes. When an action calls another action within the same system, it’s a direct method call—no RPC, no serialization, no network round-trip:
@action()
async fullProcess(input: string): Promise<Result> {
  await this.validate(input);   // Direct call
  return this.transform(input); // Direct call
}

@action()
async validate(input: string): Promise<void> { /* ... */ }

@action()
async transform(input: string): Promise<Result> { /* ... */ }

How do I cancel a running action?

The framework doesn’t provide built-in cancellation. For long-running operations, implement cancellation yourself using a state flag:
@field cancelled = false;

@action()
async longProcess(): Promise<void> {
  this.cancelled = false;
  for (const item of largeDataset) {
    if (this.cancelled) return;
    await this.processItem(item);
  }
}

@action()
async cancel(): Promise<void> {
  this.cancelled = true;
}

Do actions run concurrently?

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.

Next: React

The useSystem hook for typed state and actions