Will ChenWill Chen
← writing

IdyllicValuedeciding what one AI step passes to the next

system design7 min

While building Idyllic, I needed to decide what one step in an AI workflow should pass to the next.

The executor passed strings. I was adding workflows where people and models would edit the same document across several steps, so the value between those steps needed to preserve the document, expose addressable parts of it, and remain editable in the interface.

I named the interchange type IdyllicValue. It represents the document moving through a workflow. Defining it required separating the document from three other objects my notes also called "context": the step environment, the model request, and the execution state.

The executor was passing strings while the product was working on documents

The executor actually returned this:

{ input: string, output: string }

If a step needed structure, I asked the model to produce text in the intended shape. The next step then received that text and was expected to understand the convention. Nothing in the runtime enforced that the shape was valid or that two steps interpreted it the same way.

The examples still ran because a model can emit JSON and another model can read it. The structure existed only as an agreement between prompts. The executor neither validated it nor preserved it as a runtime object.

That fails when a value needs identity outside one call. A person edits a paragraph and a later step annotates it. The runtime needs a stable address for the paragraph and a way to preserve the rest of the document. A string carries neither.

My first implementation task was therefore to make the executor pass an actual IdyllicValue. Every later operation would depend on that runtime guarantee.

I had to stop calling four different objects context

My notes described how "context" moved between steps. The word referred to four objects with different lifetimes:

the environment a step runs in          rebuilt per step
the document resolved for this step     rebuilt per step
the request assembled for the model     rebuilt per call
the state of the running process        persists across all of them

The environment contains the capabilities and bindings available to the current step. The resolved document is the material the step is working on. The model request contains the selected document content plus instructions, provider options, temperature, and tools. Execution state persists across the workflow and records what has happened.

I banned the word context from the specification and replaced each use with the object it meant:

environment
resolvedDocument
llmRequest
executionState

llmRequest includes provider, temperature, tools, and output format as well as prompt text. executionState persists across steps. environment supplies the current step's capabilities. resolvedDocument is the value the step receives and changes.

IdyllicValue therefore represents the resolved document. The runtime constructs requests from it and records changes to it in execution state.

One document type was more useful than a type for every domain

I had previously designed semantic objects as typed domain values: a JournalCollection, a BlogPost, a HealthRecord, each with its own fields and methods.

Those types make the domain legible to the model. A journal collection can expose search by date. A health record can expose measurements. A blog post can expose sections and citations.

Each type also requires a schema, methods, a renderer, and serialization before the first workflow can use it. Idyllic needed to accept a document immediately and add behavior as the workflow developed.

I collapsed the interchange layer to one document type and moved domain-specific behavior into functions attached to it:

before   JournalCollection, BlogPost, HealthRecord, ...   one type per domain
after    Document, with functions defined on it           one interchange type

Domain-specific behavior moved into functions on the document. It no longer determined which type the executor could pass.

A generic Document does not know that one block is a blood-pressure measurement or another is a blog citation. Domain types can add that information above the interchange layer. The executor still passes the same document type between steps.

My first specification was a list of adjectives

I then listed eight properties for IdyllicValue: structured, connected, cited, human-readable, intelligible to a model, operational, composable, metadata-rich, and editable. For each property, I wrote the operation it enabled and a small demonstration of that operation:

PropertyProposed demonstrationWhat the exercise exposed
Structuredtraverse the value with a query syntaxan operation whose address model was still undefined
Composabletransform between representationstoo broad to demonstrate in one small build
Metadata-richadd a metadata fieldthe property was trivial and bought no new behavior
Editableshow the value in an editorthe editor already provided this

"Metadata-rich" implied a JSON field without naming a feature that used it. "Composable" left open whether values concatenate, merge by block identity, or pass through a function. "Editable" belonged to the product surface and was already provided by the editor.

Traversal produced a concrete operation:

idyllicValue.keys()

I was imagining something between LINQ and jQuery for documents. A step could select a subset of a document without placing the entire thing in the model's context.

The operation was to select an addressable part of the document. I postponed the method name because the representation had not yet established whether those addresses were keys, blocks, paths, or selectors.

I fixed the interface before choosing the representation

A tree made hierarchical traversal easy and cross-cutting selections awkward. A graph represented arbitrary relationships and made document order expensive. JSON exposed an implementation format to people editing a document. Using the editor's internal model would couple the protocol to the current editor.

The executor would pass an IdyllicValue interface between steps. The first implementation could remain a string internally. I would add operations to the interface only when a working use required them, then replace the representation when the string could no longer implement those operations cleanly.

The runtime could depend on the interface while the data structure remained cheap to change. Annotation would add block identities when it needed them. Selection could add paths when it needed them. No operation yet required arbitrary graph edges.

The interface was an early commitment about how steps exchanged values. The representation remained provisional until the operations made a stronger commitment necessary.

Asking what the model should do produced the first useful primitive

I replaced the property list with operations: searching, filtering, selecting, quoting, transforming, and annotating. Search needs an index or a scan. Selection needs addresses. Transformation needs a rule for preserving identity. Annotation needs a target and a removable layer over the source.

I built annotation first because it supported a complete interaction with little machinery.

Idyllic's editor used BlockNote, which already represented a document as addressable blocks. A model could return an annotation attached to a block ID. The interface could render that annotation beside the relevant paragraph, and a person could inspect, reject, edit, or delete it without changing the original text.

The Korean quiz grader provided the test. The quiz lived in the document. A grading step read the answers and attached feedback to their blocks. The feedback appeared beside the material the model had evaluated.

The annotation demonstration was the first one to force stable block addresses, a separate annotation layer, and operations for adding and removing annotations. Those became concrete requirements for IdyllicValue; the adjectives in my earlier list had produced no equivalent implementation decisions.

Annotations also keep human and model edits distinguishable. The person owns the source document. Model output sits in a separate layer with an address and provenance.

The editor model and the interchange value stay separate

BlockNote supplied the addressable blocks needed by annotation. I kept its document type out of the interchange protocol.

The editor model changes for editor reasons: rendering, selection, cursor behavior, collaborative editing, and plugin compatibility. The interchange value changes for workflow reasons: serialization, model access, operations, identity, and persistence. Sharing one type would let a UI implementation decision become part of the execution protocol.

An adapter translates IdyllicValue operations into BlockNote structures. Replacing the editor requires a new adapter while workflow steps continue to use the same interface.

A server-side step can operate on the same interchange value without loading a browser editor.

Making documents operational creates an injection boundary

Operational documents create a prompt-injection boundary. Anyone who can edit the document can place instructions where a later model will read them. If the runtime mixes document text with system instructions, a paragraph can redirect the operation meant to analyze it.

Idyllic therefore treats document content as data unless a workflow explicitly promotes part of it to instructions. Functions carry permissions, and annotations carry provenance so later steps can distinguish source content from generated commentary.

The resulting implementation

The executor passes a real IdyllicValue between steps. That value is the document. The environment, model request, and execution state have separate types and lifetimes.

All workflows use the same document interface. Domain functions add behavior. An adapter connects the interface to BlockNote, and the first implementation can remain a string behind that interface until an operation requires more structure.

Annotation is the first such operation. It requires stable block addresses, a separate layer for model output, and provenance. Those requirements now determine the representation.

This gives me a direct implementation order: pass the value through the executor, add one operation, and change the representation only when that operation requires it.