Idyllic v4compiling TypeScript classes into stateful agents

In Idyllic v4, I define an agent application as a TypeScript class. One instance represents one live session. Properties hold its state, decorated methods define the operations a client can call, and a compiler turns the class into a deployable Cloudflare Durable Object.
I chose this model because prompt-and-tool frameworks make persistent state feel external to the program. They give the model instructions and callable functions, then leave the application to name keys, serialize values, reconstruct objects, and keep the store consistent. They also reduce every interaction to a generic message even when the application has clear operations such as move, resign, or generateReport.
I wanted the source code to describe the application directly: state as properties, operations as methods, and deployment machinery generated around both.
The class owns state and compute
Most agent frameworks define an agent as a prompt plus tools. Memory usually becomes a message array backed by an external store. Even a simple phase variable needs a key and serialization code:
await store.set(`session:${id}:phase`, JSON.stringify("evaluating"));
const phase = JSON.parse(
await store.get(`session:${id}:phase`) ?? '""'
);Inside a stateful object, the same operation is an assignment:
this.phase = "evaluating";JSON encoding itself is cheap. The recurring cost is inventing keys, keeping their shapes synchronized with the code, and rebuilding typed values after every read. State that belongs to a running application should live on the object performing the work.
That gave me the base abstraction: an agent application is a program with installed modules. The program owns session state and control flow. Each module adds a configured capability, including the data it needs to operate.
One instance represents the whole session
Once the application became an object, I had to decide what one instance represented. A separate object for every agent creates several owners for state that belongs to one session. Three agents working on the same document would each need a copy or a protocol for synchronizing their views.
I use one instance for the entire live system:
one instance per agent one instance per session
agent A ── state ┌──────────────────────┐
agent B ── state │ shared session state │
agent C ── state │ │
coordination │ agents are functions│
protocol └──────────────────────┘The session object owns the board, document, accumulated findings, and other shared state. Specialized agents run as functions inside it. They can still have separate prompts, context, and data, but they do not need a conversation protocol to agree on the state of the work.

The client interacts with one AgenticSystem. Whether the implementation uses one model call or ten agents in parallel stays behind that boundary.
Actions use the verbs of the application
Generic run and onMessage methods work naturally for chat. They become awkward as soon as the application has operations that users already know how to name.
Chess made this obvious. A message interface encodes the move inside text and parses it on the server:
agent.onMessage("I'd like to play e4");The domain already has a better interface:
agent.move("e4");
agent.resign();
agent.offerDraw();Methods give the frontend real signatures, make invalid calls visible to TypeScript, and let each application expose something more specific than sendMessage.
I mark remotely callable methods with @action(). Public methods remain ordinary helpers unless I explicitly add the decorator. This keeps a local refactor from silently changing the network API.

Fields define the synchronized client state
I use the same rule for values displayed by the interface. A synchronized value is a decorated property:
export default class SimpleSystem extends AgenticSystem {
@field query = "";
@field count = 0;
@action()
async increment(amount?: number) {
this.count += amount ?? 1;
}
}@field exposes state to connected clients. @action() exposes a method they may call. Assigning to this.count updates the clients, so application code does not contain a separate broadcast operation.
Model output needs a second field type because streaming text has a lifecycle. A normal value changes atomically; a stream receives chunks and eventually completes:
@field problem = "";
@field hypo1 = stream<string>("");
@field hypo2 = stream<string>("");
@field hypo3 = stream<string>("");A stream supports append, complete, and reset. Three model calls can write to three fields in parallel with Promise.all. The paths separate their output, so I do not need to multiplex several generations through one application-level channel.

The wire messages remain small:
{ "type": "stream:append", "path": "hypo1", "chunk": "The key insight..." }
{ "type": "stream:complete", "path": "hypo1", "value": "The key insight is..." }
{ "type": "action", "action": "generate", "args": [] }The framework owns transport and synchronization. Application code decides when to append history, create an artifact, checkpoint state, or complete a stream.
History entries remain extensible
Conversation history cannot be a closed list of text messages if modules can introduce plans, artifacts, charts, or structured tool results. Encoding every new entry as text creates a second informal protocol inside the first.
In Idyllic, a module can define a history entry type together with two conversions: how the interface renders it and how the model sees it. A chart can remain structured in the application while producing ordinary model messages when it enters inference context.
This boundary keeps the framework from deciding the shape of every application built on it. Idyllic moves history entries and synchronizes them. The application and its modules own their meaning.
The source class compiles into a different program
The class above is the code I want to write. Cloudflare requires a Durable Object export, storage bindings, request routing, and lifecycle hooks. Those are different programs connected by a source transform:
authoring model TypeScript class with fields and actions
deployment model Durable Object export with storage and routing
compiler transforms the first into the secondSeparating them let me design the source API around application code and the generated output around the platform. The compiler is the layer that makes both descriptions true.

I originally approached Idyllic as a custom language for AI applications. TypeScript already supplied the parts I would have had to rebuild: types, editors, imports, packages, control flow, and familiar object composition. Idyllic therefore restricts and transforms a subset of TypeScript instead of inventing new syntax.
The source must continue to read and behave like an ordinary class. Code that does not use an Idyllic construct keeps normal TypeScript semantics. The transform only gives additional behavior to explicit fields and actions.
I also keep the deployment target out of authored code. The compiler generates the routing worker that locates the correct session instance. Local development runs the same transform through Miniflare, keeping local and deployed semantics aligned.
This removed Wrangler from the application-facing workflow. Hiding it behind another command would still expose its configuration and errors. Using Miniflare directly gave Idyllic one runtime path that I could control.
The protocol sets the limit of the transform
I designed the wire protocol before finalizing the compiler rules. The protocol determines which state transitions the runtime can represent. The runtime then determines which source constructs the compiler can support honestly.
protocol → runtime behavior → source constructsBecause the protocol has a field-update event, the compiler can turn assignment to a decorated field into a state change and broadcast. Because it has stream append and completion events, stream<T> can expose those operations directly. Because it has an action call, a decorated method can become a typed remote procedure.
Anything outside that protocol remains local TypeScript. This gives the transform a clear boundary and prevents convenient source syntax from promising behavior the runtime cannot deliver without hidden round trips.
The ordering also made persistence easier to reason about. A field update has one runtime meaning whether it originated from a local action, a model callback, or an external event. The storage and broadcast behavior attach to that transition instead of being reimplemented at every call site.
Modules bring the state behind their operations
Tools expose functions and leave their storage to the application. Idyllic modules package the operations with the data model and configuration they require.
A Telegram module, for example, can provision message and contact tables, retain conversation state, install its prompts, and export operations such as sendMessage and getConversationHistory:
install Telegram module
brings
messages table
contacts table
conversation state
conversation prompts
exports
sendMessage
getConversationHistoryInstallation is a provisioning step. The application receives a configured stateful resource instead of a function whose memory must be assembled elsewhere.

This is the module boundary I care about: a module owns the state required to make its operations meaningful. Stateless capability can remain a tool. Stateful capability arrives with its tables, migrations, prompts, and lifecycle.
The result resembles a small cloud environment scoped to one agent application. Installed modules provide resources with operations defined over them, while the session program composes those resources into behavior.
Durable Objects match the resource model
The transport did not decide the platform. Server-sent events, WebSockets, and hosted realtime services can all carry the protocol. I needed an addressable live session located beside persistent storage.
Durable Objects provide that resource directly: one named JavaScript instance with attached storage. The infrastructure now matches the source abstraction. One class instance represents one session in the program, and one Durable Object represents that session when deployed.
Ordinary serverless functions could run individual actions, but an external event or background operation also needs to update the session and deliver changes to connected clients. I use request-response execution for the application logic and keep a long-lived connection for delivery.
That division fits an edge isolate. A general container would support a wider range of workloads at the cost of a heavier deployment model. Idyllic only needs the execution and storage behavior required by its class model.
The deployment layer uses Cloudflare Workers for Platforms with Durable Objects underneath. Idyllic owns code upload, transformation, routing, and the developer-facing lifecycle. Cloudflare owns the machines and stateful runtime.
Fast delivery is infrastructure, not the product's reason to exist. "Ten agents streaming" only describes traffic. The application model matters because those agents are updating typed state through named operations inside one shared session.
The same boundary improved debugging
Named actions make a live session inspectable through RPC. A coding agent can connect to a running instance, read its fields, call the same actions as the browser, and observe the resulting state transitions.
The interface updates while the coding agent operates the application, so I can watch a debugging session happen through the product itself. I did not need a separate control protocol; debugging reuses the domain methods and synchronized fields already required by the application.

This is a useful test of the abstraction. A second kind of caller can drive the same object without converting every operation back into text messages.
The class carries the application boundary
Idyllic v4 moves the important boundaries into the source type system. Persistent state is a property instead of a key in an external store. Client operations are methods instead of instructions hidden in messages. Remote access is explicit through decorators. Streaming output is a typed field with a defined lifecycle.
The compiler preserves that authoring model while generating the storage, routing, synchronization, and deployment code required by Durable Objects. Modules package stateful resources together with their operations.
The final model is compact: one class represents a live agentic system; its fields hold shared state; its actions expose the verbs of the application; installed modules bring their own storage and operations. TypeScript describes what differs between applications. The generated runtime carries everything else.