ObjectEnva workspace instead of a list of tools
ObjectEnv gives an agent a workspace instead of a toolbox. Objects live as JSON in SQLite, each one addressed by name and carrying its own methods, and the agent works by invoking those methods rather than by calling stateless functions and re-reading the results.
The hard part was deciding what an object must carry so that a reader which has never seen it can use it correctly on the first attempt, and then noticing that the most useful thing to put in such an interface is an operation the agent is not allowed to perform.
The design below runs from the problem with tools through to the one part of it I would build again.
A tool has no memory, and a tool protocol has no variables
A tool is a function call. The model asks for something, a result comes back as text, and the model reads that text and decides what to do next. Each call is complete in itself, so anything the model wants to keep it keeps in the conversation and re-reads on the next turn.
For a short job the missing memory is invisible. For anything that runs a while it becomes the whole problem, because the model carries its working set inside the transcript and pays for all of it on every call. MCP has calls but no pipe-level composability: no variable a tool can write and a later tool can read by name.
shell grep ... > hits a name to write to
sort hits | uniq -c a later command reads that name
tools search(...) -> "..." result comes back as text
summarize("...") the text has to be carried in the transcript
A shell pipeline composes because a command can write to a name and a later command can read that name. A tool protocol has the commands and no names, so the only place to put an intermediate result is the transcript.
The design goal follows directly: give the agent stateful objects it can reference, pass between calls and manipulate through methods. Combining state with the operations that change it follows the actor model more closely than separating a stateless reasoner from a generic memory service.
Splitting an agent into a stateless reasoner and a memory service gives you two things that must be kept in agreement. Putting the state on the object with the operations that change it gives you one.
An agent loop can call, and it cannot return
A second problem appears when an agent has to be called from a program rather than chatted with. Its behavior is buried in a framework-specific message log, streaming is coupled to the chat transport, required behaviors are offered as optional tool calls, and the loop has no clean way to pause for a program-defined branch.
The third one is the one the whole design turns on. Offering a tool is a request, and a request the model can decline is not a constraint. Everything a caller wants to guarantee has to be arranged so that declining is impossible rather than merely unlikely.
The fourth points at something more structural: a tool call continues the agent loop, so the agent has no ordinary way to return control and a value to its caller.
A function that finishes can do one of two things: call something else, or return, meaning hand a value back to its caller and disappear. An agent loop only has the first, because every tool result is fed back in and another turn runs.
The usual approximation is a finish tool watched for from outside, which works the way a goto works, meaning fine until you want two of them nested. Lisp calls the general machinery for this a continuation, and an agent runtime needs only the ordinary half of it, which is a way to end the loop with a value instead of with another message.
A runtime, or a file with a script beside it
Two designs answered the requirement. The first was a virtual runtime of persistent JavaScript-like objects backed by SQLite and exposed through one tool surface. The second was a file carrying its own data and code, one format per kind of artifact. I chose the runtime because one global environment gives every object the same storage and invocation path, while file-specific objects require custom schemas and readers for every kind.
One environment with a global scope has one storage layer and one invocation path, so adding a kind of object costs a class definition. A file-per-artifact design multiplies both by the number of kinds, and each kind then needs its own schema, its own reader and its own tools.
The remaining objection was whether the runtime bought anything a file plus a script did not. The object could exist only while the script was running and still persist its data in the file.
Two months later that objection collected its answer.
The build was scoped for speed rather than architecture. Moving from a hosted sync engine and server-side WebAssembly to local SQLite and TypeScript removed deployment from the iteration loop.
The earlier attempt at the same idea ran on a hosted sync engine with object schemas defined in it, which put a deployment between every change and every observation. A local file and a local process removed both, and the idea survived contact for the first time.
The interface has to describe itself, because nothing human will read it
The object type has to answer a question an ordinary class never has to. What must an object carry so that something which has never seen it before can use it correctly on the first attempt?
A class in a normal program does not carry that and does not need to. The names do most of the work, the signature does the rest, and the documentation is a courtesy for whoever maintains it later. Here the caller is a model that arrives with no prior exposure and does not get a second attempt for free.
export interface ObjectClass<S = State> {
name: string;
description: string;
defaultState: S;
methods: Record<string, MethodDef<S>>;
}
export interface MethodDef<S = State> {
description: string; // what this method does and when to use it
example?: string; // example usage
fn: (state: S, ...args: any[]) => any;
}A lock, whole, is small enough to read as one thing:
const Keypad: ObjectClass<KeypadState> = {
name: "Keypad",
description:
"A digital keypad lock. Enter the correct code to unlock. " +
"Wrong attempts are tracked.",
defaultState: {
code: "1234", entered: "", maxLength: 4,
attempts: 0, maxAttempts: 5, locked: true,
hint: "4 digits required",
},
methods: { look: { /* ... */ }, press: { /* ... */ } },
};Everything an agent needs to operate it is in the value. Nothing is in a prompt, and nothing is in a comment.
Only description is required, and the example is optional because it earns its place only where the call shape is not obvious from the name. A worklog's log method carries invoke("daily-log", "log", ["Implemented feature X", ["dev", "feature"]]), which shows the positional array and the nested tag list in one line, and an agent that has never seen a worklog can construct a valid call from it.
Putting description on the type rather than in a doc comment is the decision. A comment is advisory and a field is not, so a class that does not describe itself does not compile, and there is no path by which an undocumented method reaches an agent.
Objects live as JSON in SQLite and are hydrated with their class only when a method is invoked, which makes the object virtual. It exists during the call and is a row the rest of the time.
The whole surface is three verbs:
const env = createEnv("./my-env.db");
env.create("Counter", "page-views");
env.invoke("daily-log", "log", ["Finished implementing feature X"]);
env.inspect("daily-log"); // -> methods, each with its descriptioninspect is the one that matters, because it is how an agent finds out what an object can do without anybody having put that in a prompt.
Constrain the interface rather than instruct the agent
The requirement appears with the first object big enough to matter. A worklog holding two years of entries is too big to read, and the obvious interface hands the agent a getAll and lets it discover that by running out of context.
The instinct is to fix it with instruction: tell the agent to check the size first or process long logs in chunks. That remains probabilistic and has to be repeated in context, so the size bound belongs in the interface instead.
An instruction is a probability, and it decays with distance from where it was given. An agent that has forgotten looks exactly like one that was never told, which makes the failure impossible to diagnose from the outside.
So the worklog has peek, marked in the source under the comment Constraint methods - help agent reason about scale:
peek: {
description: "Get metadata about the worklog without loading entries. " +
"Use this FIRST to understand scale before deciding how to process.",
example: 'invoke("daily-log", "peek")',
fn: (state) => ({
count: state.entries.length,
earliest: /* oldest timestamp */,
latest: /* newest timestamp */,
tags: /* every tag in use, sorted */,
}),
}A count, a time range, and the tag vocabulary. No entry ever comes back. The instruction to call it first sits in the description because the description is what the agent reads, and it holds without being remembered, because the return type makes any other order pointless. Nothing gets an entry out of peek, so an agent that wants entries has to go somewhere else and pick a slice.
Underneath it sit readers that take slices rather than everything. range between two timestamps, whose own description says it is for chunked processing of large logs, plus lastN, lastHours and today. None does anything a getAll could not do, and each one makes a shortcut unavailable.
The general form is that a degree of freedom you remove cannot be used wrongly, and a degree of freedom you ask politely about will be used wrongly at some rate you cannot measure.
The journal indexing run
The test was two years of journal data that needed indexing and summarising. Rather than writing the processing logic, I created the object types, populated the environment with the data, and asked the agent to work out a strategy.
It chose to chunk by time period, summarise each chunk, and then synthesise the summaries into a whole, which is map-reduce, and nobody wrote the loop.
The narrow reading is the one worth keeping. The affordances already pointed at chunking, since one method says to call it first to understand scale and another says it exists for chunked processing of large logs. The agent supplied the axis to cut on and the decision to synthesise rather than concatenate, neither of which anybody had specified.
Which is the result the design was actually testing. Constraining an interface changes behaviour more reliably than instructing it does, because making the contents unavailable until the agent has asked how big they are is not something it can forget.
A search with no way back is a worse algorithm
Branching was added after watching agents work, rather than from anticipating that they would need it. An agent exploring a problem hits dead ends, and with no way back it does one of three things: burns tokens reversing its own moves by hand, loses the state and starts over, or stays in a bad position because getting out costs more than continuing.
The mechanism is one table. Every state-changing call gets a row carrying the state before it and the state after:
export interface MutationRecord {
id: number;
branch: string;
object_id: string;
object_name: string;
method: string;
args: string; // JSON
before_state: string; // JSON
after_state: string; // JSON
created_at: number;
}Once that table exists, the rest is a query against it. A checkpoint is a marked row, an undo reverts the last n mutations by writing before_state back, and a branch is the branch column, so forking the timeline at a row lets two strategies run from the same position:
export interface BranchRecord {
name: string;
parent_branch: string | null;
fork_point_id: number | null; // the mutation this branch forked at
created_at: number;
}Three features and one implementation.
The maze solver demonstrates it. The agent gets look, move, checkpoint and restore, and the checkpoint method's description tells it to save before exploring a path that might be a dead end. In the run recorded at the time it escaped in twelve moves after exploring six dead ends, using six checkpoints and four restores.
The loop in that maze is decide, observe, update, decide again, which is search in the ordinary computer science sense. Every search has a way of backing up: recursion unwinds the stack, and an iterative version keeps an explicit one. An agent in a loop has no stack to unwind, so until you hand it checkpoints it is running a search with the back-up step deleted, which is a different and much worse algorithm.
State as the coordination mechanism
The murder mystery is the demo that shows why a workspace beats a message bus. Three suspects, each an object, and the state is where the whole game lives:
interface SuspectState {
alibi: string; // what they claim
isGuilty: boolean; // hidden truth
secretMotive: string;
actualWhereabouts: string;
trustLevel: number; // 0-100
timesQuestioned: number;
gossip: Array<{ about: string; info: string; requiresTrust: number }>;
reactions: Record<string, Reaction>; // keyed by evidence id
}Nothing there is a message. Showing evidence to a suspect changes that suspect, questioning them changes what they will say next, and pressing too hard costs trust that gates the gossip you wanted. The investigation is a walk through state, so the solution emerges from the order the agent chose rather than from a path anybody laid down.
Two agents working the same case need none of the machinery a message protocol would require, because they are reading and writing the same suspects. Humans collaborate the same way, working on shared documents and boards rather than only talking, and the shared artifact is what makes the talking optional.
What the pattern kept when the runtime went
The later rebuild changed the substrate under one principle: Linux already supplies the persistence, naming, composition and process model, so the system should compose those facilities rather than recreate them.
Under that principle the agent is the shell and the filesystem is the store, and the three things ObjectEnv supplies as a runtime are already sitting there. Files persist, are addressable by path, and can be passed around by name. Git has branches and checkpoints. Git also has the mutation log, for the same reason.
Which is the answer to the objection I had raised against my own design on the first day, about what a runtime buys over a file with a script beside it. It buys the invocation path and the storage layer, both of which an operating system already provides.
The filesystem has no equivalent for the bounded methods, which are a design pattern rather than a package. A peek that structurally cannot return an entry is something the application still has to define.
The transferable parts:
- Put state on the thing that computes with it, so there is one object to keep correct rather than two systems to keep in agreement.
- Give the workspace names, because composition needs somewhere to put an intermediate result that is not the transcript.
- Ask what the interface must carry for a caller that has never seen it and gets one attempt, then make that a required field rather than a comment.
- Remove the shortcut instead of asking the caller not to take it, because an instruction decays with distance and a missing method does not.
- Design the return type so the intended order is the only order that gets anywhere.
- Give an exploring agent a way to back up before you give it anything else, since a search without one is a different algorithm.
- Log before and after state once, and take checkpoints, undo and branching as queries over the same table.
- Check whether the operating system already implements the runtime you are about to write, and keep the part it does not.
The bounded methods remain useful. I would build them again on top of the filesystem instead of maintaining a runtime of my own.