* fix(desktop): keep a program word and a directory's neighbor The program after env stays the program even when a captured file sits in a folder of that name. A selected directory is not a database, so a neighboring file keeps the parent folder's spelling. * fix(desktop): record whether a selected path is a directory Capture stores the walked path's kind. A neighboring file of a selected directory keeps the parent folder's spelling even when that directory never changes and its children are not content roots. * fix(desktop): mark containing paths without a pairwise scan A recorded path marks its known ancestors as directories in one walk up the path, instead of comparing every recorded path with every other. * fix(desktop): keep a selected folder that is named like a database log A folder the caller selected stays a selected root when its name ends in -journal or -wal. A generated log beside a database file still does not. * fix(desktop): refuse a build command that exceeds the argument vector Splitting one argument still leaves the whole command under Linux's E2BIG limit. A request past that budget is refused before the process starts. * fix(desktop): remap a captured path in an env assignment The program word stays the program. A NAME=value assignment keeps its name and follows a captured path value when that path's spelling moves. * fix(desktop): keep oversized text out of draft guards A value or line longer than the draft's text bound is not copied into a guard, so one large frame cannot fill the task by itself. * docs(desktop): correct dconf and check-preview guidance A dconf version kept without its bytes is unreadable, not an empty key list. A values preview is copied into a check only when it is an uncut scalar. * fix(desktop): keep an empty env value empty An empty assignment is not a path. Resolving it named the working directory and rewrote the value when that directory's spelling moved. * fix(desktop): keep a run's font on the run, not the frame's text A formatting attribute such as Scribus FONT is not the document's words. Joining it across runs made a frame-wide text check of ItalicBold that failed when a run was split. The font stays a value check on its own run. * fix(desktop): do not pair JSON records by a selector-shaped field A field named @record_id matched the id suffix and became [@record_id=one], which reads an XML attribute. Records paired while their pointers matched nothing. Names that start with @ or > are not id fields. * fix(desktop): count a save-numbered id once when pairing members xml:id and draw:id values such as id123456 were both generated and numbered, so the same selector was counted twice and refused. A text edit of two elements keyed only by those ids now keeps its check. * fix(desktop): preserve selected state directory aliases * docs(desktop): explain tool and skill refresh prerequisites * fix(desktop): align nested state selections with aliases * fix(desktop): check build runtime export capability * fix(desktop): provide complete runtime repack command * fix(desktop): retain runtime probe failures * test(desktop): identify capture independently of schema preflight * fix(desktop): preserve document URLs and value formats * fix(desktop): bound added checks and omit noisy text guards * fix(desktop): exclude unseeded locks from task checks * fix(desktop): preserve draft bounds and launch URLs * fix(desktop): render draft values using their source format * fix(desktop): preserve launch URLs and JSON draft values * fix(desktop): recognize captured file URL documents * fix(desktop): render captured file URLs for runtime home * fix(desktop): distinguish phone numbers from save metadata * fix(desktop): preserve launch URLs and user text * fix(desktop): preserve captured URLs and user text * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): preserve local file URL authority * fix(desktop): render captured file URLs at launch * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): classify numeric timestamps using field context * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * fix(desktop): retain context for generated draft values * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * test(desktop): cover generated IDs in text guards * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): isolate launcher URL encoding * fix(desktop): retain clean text in noisy record fields * fix(desktop): classify added line values in one pass * fix(desktop): apply XML quote rules to text drafts * fix(desktop): distinguish XML attributes from quoted text * fix(desktop): preserve parsed XML line context * fix(desktop): retain partial XML syntax facts * fix(desktop): recognize markdown autolinks in drafts * fix(desktop): reuse export exclusions in task drafts * docs(desktop): clarify gzip scrub limits * style(desktop): format the noisy-record draft files with the pinned oxfmt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(desktop): move launch execution to boundary suite * fix(desktop): redact build schema failure output * refactor(desktop): normalize schema failure once * fix(desktop): omit unsafe schema parser snippets * fix(desktop): preserve draft change and selected path evidence * test(desktop): group retained dconf note regressions * fix(desktop): respect archive entry format in draft checks * test(desktop): separate unowned-file draft note cases * fix(desktop): preserve explicitly selected journal documents * fix(xml): avoid scanning selected ancestors per tag * fix(desktop): exclude covered text runs from draft counts * fix: preserve text when drafting desktop checks * fix: restore nested desktop state aliases * refactor: share UUID recognition across draft checks * fix: retain multiline XML attribute context * fix: retain relationship identifiers in task drafts * docs: clarify desktop draft identifier handling * test(desktop): type formatting run cases as pairs * test(desktop): separate markup boundary regression group * test(desktop): remove duplicate seed filename case * docs(desktop): clarify task checks before scratch export * fix(desktop): respell selected paths in file URLs * test(desktop): group captured document spelling cases * fix(desktop): bound draft preview serialization * fix(desktop): restore selected state aliases * fix(desktop): respect entry format in draft noise * test(desktop): match exported alias evidence at its JSON boundary * fix(desktop): retain app state logs in exported seeds * fix: align desktop numeric seed comparisons * test(desktop): keep one draft exclusion fixture import * test(desktop): retain log eligibility across draft test groups * style: fix desktop launch test formatting * style: format native command path schema * fix(desktop): map selected external launch paths * style(desktop): format seed document regressions * test(desktop): group launch URL regressions * fix(desktop): omit redundant nested state aliases * fix(desktop): compare added free text as a whole * fix(desktop): check recompressed seeds as shipped * docs(desktop): clarify recovered seed format * test(desktop): group shipped gzip seed checks * fix: retain quoted identifier text in task drafts * fix: recognize Unicode XML in desktop state readers * fix(desktop): bound nested draft checks * fix(desktop): explain missing arrays in truncated views * fix(desktop): find XML type references in archives * test(desktop): remove duplicated capture assertions * fix(desktop): retain nested omitted text boundaries * style(desktop): format draft value imports * test(desktop): group model and draft value checks * fix(desktop): map captured launch paths once * refactor: remove unused whole-text guard state * refactor: stop passing unused text children * test: align free-text checks with whole-note policy * fix: validate recompressed desktop archive seeds * style(desktop): format archive seed regression * fix(desktop): retain placed-item sibling guards * fix(desktop): redact runtime schema errors * fix(desktop): normalize captured file URLs * fix(desktop): report seeded files by capture path * fix(desktop): retain startup readiness failure stage * fix(desktop): retain JSON date checks * fix(desktop): preserve archive formatting checks * fix(desktop): preserve explicitly selected journal files * fix(desktop): report build command transport limits * docs(desktop): clarify unreadable dconf versions * test(desktop): remove duplicate history regression * fix(desktop): align XML names with exported reader * fix(desktop): scrub XML independently of reader compatibility * style(desktop): format review fixes * style(desktop): format capture review fixes --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
Desktop build computer
A build computer is a local agentenv computer that an agent drives while it builds a desktop app environment. Claude Code cannot attach a new MCP server in the middle of a session, so REA keeps the computer up and forwards the agent's computer-use actions to it through three MCP tools:
| Tool | What it does |
|---|---|
start_build_computer |
Starts a computer from a request or a bundle folder; returns its id and a first screenshot |
act_on_build_computer |
Sends one action to the computer's driver and returns the driver's answer, images included |
close_build_computer |
Tears the computer down, by agentenv's teardown or by removing its sandbox's containers |
The tools are MCP-only, like the debugger sessions: the computer lives as long as the REA MCP server that started it, or until it is closed. Capturing the app, exporting its bundle and witnessing the task on such a computer are in Building and witnessing an environment, and REA's skill for doing all of it from a request is desktop builds.
Requirements
- A Linux host on amd64 or arm64 with about 20 GB of free disk for the images agentenv builds and pulls. amd64 and arm64 hosts both build natively; a host builds only its own architecture.
- Node.js at the version in
.nvmrc, installed with nvm or from nodejs.org, and REA run from a checkout. - Docker Engine and its
post-install steps:
add yourself to the
dockergroup, then log in again. - agentenv-framework 0.9.1431, the version bundles target. Install
uv first, then
run
uv tool install agentenv-framework==0.9.1431. SetREA_AGENT_ENV_COMMANDto the absolute path of the resultingagent-envprogram, normally$HOME/.local/bin/agent-env;command -v agent-envshows the path when its install directory is on yourPATH. Installing it with pip into a virtual environment instead needspython3.12-venvon stock Ubuntu. REA runs its helper,bridge/agentenv/computer.py, with that runner's Python: the interpreter on the program's#!line, or thepython3beside it in its virtual environment. REA_DOCKER_COMMAND, the absolute path of the Docker CLI, lets REA remove a computer whose agentenv teardown was not confirmed. Without it, REA names the command to run instead.- For a request,
REA_BUILD_COMPUTER_REA_ARCHIVE: the absolute path of a tar archive of a runnable REA, with its production dependencies, whose one top folder holdsscripts/rea.mjs. The computer runs REA from it. From a checkout,npm run pack:build-computer-runtime -- /path/to/rea-prod.tar.xzwrites one:npm pack, its package folder asrea-prodwith the lockfile,npm ci --omit=dev --omit=optional, thenrea-prodas.tar.xz. The archive is a copy of the checkout: pack it again aftergit pulland a rebuild.
Set these where the MCP server starts; a shell export does not reach a server that is already running.
Claude Code
rea setup --client claude_code registers REA without these variables. Either
export them in the shell that starts claude, which starts REA's server with
its environment:
export REA_AGENT_ENV_COMMAND="$HOME/.local/bin/agent-env"
export REA_DOCKER_COMMAND="$(command -v docker)"
export REA_BUILD_COMPUTER_REA_ARCHIVE="$HOME/rea-runtime/rea-prod.tar.xz"
claude
or set them, as absolute paths, in the env of the mcpServers.rea entry
setup wrote, such as in ~/.claude.json:
{
"mcpServers": {
"rea": {
"command": "/absolute/path/to/node",
"args": ["/absolute/path/to/rea/scripts/rea.mjs", "mcp"],
"env": {
"REA_AGENT_ENV_COMMAND": "/home/ada/.local/bin/agent-env",
"REA_DOCKER_COMMAND": "/usr/bin/docker",
"REA_BUILD_COMPUTER_REA_ARCHIVE": "/home/ada/rea-runtime/rea-prod.tar.xz"
}
}
}
}
Solver runs are agent-env run commands the agent runs in its shell, and
agentenv refuses them without a model endpoint: export LITELLM_BASE_URL and
LITELLM_API_KEY in the shell that starts claude, whose Bash tool inherits
them, or set base_url and api_key under [model] in
.agentenv/config.toml.
Claude Code asks before each MCP tool call its permission settings do not
allow, and a build makes dozens of act_on_build_computer calls. You can
allow REA's tools in those settings' permissions.allow list:
mcp__rea__act_on_build_computer allows that tool, and mcp__rea allows all
of REA's.
A headless run, claude -p, ends when the skill asks its upfront questions,
and refuses the tool calls its permission settings do not allow. Put the
answers in the request: the install source when the app is not in the Ubuntu
archive, a synthetic seed or the file to start from, whether to review the
task before solver runs, and the solver runs' count, model and budget, or
none:
claude -p 'Build me a Gnote environment where the agent adds olive oil to the
shopping list note. Make a synthetic note. Then make 2 solver runs with
<model>, at most $4 in all, without a review first.'
Start a computer
From a request, REA renders a bundle in a private temporary folder: a computer
app on the ubuntu-24.04 base with the reference driver that installs the
app, opens it on demand with gtk-launch <desktop entry> from the computer
user's home, so its open and save dialogs start there rather than in the
framework's folder for the app, and keeps no seed and no data hooks. The app is not open when the computer starts:
start_build_capture opens it under REA's capture. When no capture is under
way, open it from the desktop's Applications menu, where agentenv lists it
under Other by the environment's name, such as mousepad; that entry launches
it through the app's server. The name is the desktop entry's last part when it
is reverse-DNS (com.github.xournalpp.xournalpp gives xournalpp), or else
the whole entry without a trailing version (glabels-3.0 gives glabels).
When the app is Electron, set electron to true. The install rewrites that
desktop entry's command with the same policy an export uses: --no-sandbox
immediately after the program, unless the command already has it. The
computer's container does not grant the user namespaces Chromium's sandbox
needs, and Electron exits without the flag. With the rewritten command, the
Applications menu entry can open the app.
{
"source": {
"kind": "request",
"desktop_entry": "org.xfce.mousepad",
"window_class": "Mousepad",
"install": { "kind": "archive", "package": "mousepad" }
}
}
install takes one of:
kind |
Installs |
|---|---|
archive |
package from the Ubuntu 24.04 archive, on amd64 and arm64, with its optional extra_packages beside it |
apt_repository |
package from the repository at uri with suite and components, trusting the key file key_file |
package_file |
The .deb at url, checked against sha256, for one architecture, after its apt dependencies are in |
Build steps run with the network off, so a .deb's dependencies must be
listed. extra_packages names up to 16 more archive packages, by the same
name rules as package, for a runtime piece the app needs that its package
does not install: apt here skips Recommends, so MyPaint 2.0.1 exits at launch
without librsvg2-common, GTK's SVG icon loader, and Freeplane 1.7.10 needs
openjdk-17-jre, since its launcher refuses noble's default Java 21. The
export installs them beside the app too, as it does a .deb's
dependencies when apt offers the .deb's package; see
build and witness.
The bundle adds REA in a build-only layer: Node.js
24.22.1, downloaded per architecture and checked against its release checksum,
and REA from REA_BUILD_COMPUTER_REA_ARCHIVE, both unpacked into /opt/rea by
a root build step, with node and rea on PATH. It also installs
libgtk-3-bin, for gtk-launch, and dconf-cli, so the capture reads the
app's settings in dconf, such as GnuCash's "Show tips at startup", and the
seed keeps them. An exported bundle carries none of it but dconf-cli, which
it installs when it loads the app's dconf keys.
From a bundle folder, such as one export-desktop-application wrote, REA
starts that bundle's tasks/smoke.json as it is:
{ "source": { "kind": "bundle", "path": "/home/ada/mousepad-bundle" } }
REA runs its helper with PYTHONDONTWRITEBYTECODE=1, which agentenv's own
import of the bundle's app class inherits, so a start leaves no __pycache__
in the folder and its files stay as they were shared.
REA runs the deploy with run_bundle(keep=True, emulate=False) on the local
sandbox provider, so only the host's own architecture is built. The first start
of a bundle builds its image and can take minutes; later starts reuse it.
agentenv makes a start wait while another agentenv run on this host writes its
bundle's ids, so starts at the same time wait for each other, several minutes
when a few run together; the progress line then says it is waiting for another
agent-env run to finish writing this bundle's ids.
While it waits, REA sends an MCP progress notification every five seconds with
agentenv's latest line, so a client that resets its request timeout on
progress keeps waiting. REA then waits until the computer's readiness checks
pass and opens one MCP session to its driver. Readiness can pass before the
desktop has drawn, so REA takes a whole-screen PNG screenshot every 500 ms
while it is one colour throughout. The retries share 10 s, each given only
what is left of it, and when that runs out REA returns the latest frame;
progress notifications continue meanwhile. Given screenshot with a
region, format or quality, REA then takes one with them, a region
cropping that frame rather than capturing the screen again. The result holds
computer_id, agentenv's sandbox_id, the environment name or bundle folder,
extra_packages, what the request named beside the app for the export to
install too (an archive install's extra_packages or a .deb's
dependencies, empty otherwise), and the screenshot's frame as structured
content beside its image.
A failed start returns the last 16 KiB of the helper's stderr, where a failing
build prints its error, and its exit status; a cancelled one stops the helper,
which tears down whatever agentenv brought up. When that teardown is not
confirmed, the error's computer_id names what REA keeps for
close_build_computer to retry. Closing the server cancels starts still in
progress and waits for them before closing every computer.
Act
act_on_build_computer takes computer_id, an action and the driver's own
arguments for computer_<action>, which REA passes through:
action |
Arguments |
|---|---|
screenshot |
region, format (png or jpeg), quality |
click |
x, y, button, count (2 double-clicks), modifiers |
drag |
path of 2 to 50 [x, y] points, button, duration_ms, modifiers |
scroll |
direction, x, y, amount, modifiers |
type |
text, where \n presses Enter and \t Tab |
key |
keys, one chord pressed in order, and repeat |
wait |
ms |
reset |
screenshot |
reset is not a driver tool: REA calls the app's data/reset, which restores
the bundle's seed and relaunches the app, then waits until the computer's
readiness checks pass again, sending an MCP progress notification every five
seconds while it waits. With screenshot: true it then takes a screenshot; if
that fails, the reset still counts as applied and its result has no frame
but a screenshot_error with the computer's message. A computer started from
a request has no data operations, so it refuses a reset.
Pointer actions also take frame_id, space and norm_scale, and every
action but screenshot takes screenshot: true for an image afterwards. That
image is taken at once, so after an action that opens a menu or a window, ask
for it with a wait of a few hundred milliseconds instead.
Points are pixels of the latest frame, so take a screenshot first; with
space: "norm" they are on a 0..norm_scale grid over it. Keys are W3C names
or one character; REA translates model spellings such as ctrl+s, Return,
esc and cmd and leaves other names for the driver to judge. An argument the
action does not take is refused before anything is sent.
The result carries the driver's content unchanged, its text and images, after
REA's own JSON: action_index and the driver's structured result. A refusal,
such as a stale frame, is an error carrying the computer's message. A response
larger than the MCP budget is refused with how to ask for a smaller image: a
region, or jpeg with a lower quality. A screenshot with region does not
capture the screen again; it crops the latest frame, so take a full
screenshot, or a wait with screenshot: true, first. That error keeps REA's own result,
computer_id included, as details.result, since the action or start has
happened.
Actions, the capture steps and the task checks on one computer run one at a time, in the order REA receives them; one that has to wait for the call before it sends an MCP progress notification every five seconds meanwhile. Different computers run side by side.
Every action is logged on the computer in order with the arguments sent, its
start and end and whether it was applied, refused or failed. The log keeps the
latest 256 actions within 256 KiB of JSON, dropping the oldest first. Each
string argument, such as type's text, is kept to its first 1 KiB and each
list, such as drag's path, to its first 64 items; an entry names the
arguments it cut in truncated_arguments, and the driver gets them whole. The
log counts every action, so it says how many it dropped, and keeps the index of
the last applied action of each kind for rules over the whole session. The log
and the computer's recording hooks (computer-recording/v1 start and end under
REA's own request id, and the segments fetched through the driver's data/get
one at a time, each read within its manifest size and checked against it) are
what the witness records; they are not tools of their own. A recording larger
than a quarter of the V8 heap is refused before any segment is read.
Close
close_build_computer ends REA's MCP session and closes the helper's stdin,
on which the helper runs agentenv's teardown. When agentenv confirms nothing is
left, the result says teardown: "agentenv". Otherwise REA removes the
containers of that exact sandbox: agentenv runs a computer as
agent-<sandbox id> without a label and labels its other containers
agentenv.sandbox=<sandbox id>, so REA lists both with docker ps -aq --filter, removes them with docker rm -f, and says
teardown: "container_label". When neither confirms it, the error names what
is left and the computer stays listed, so closing it again retries. A close
cancels the call in progress on the computer, refuses the calls waiting for
it, and tears down once that call has ended. Closing the
MCP server closes every computer the same way, and a helper whose REA server
exits sees its stdin close and tears down.
The control token that guards the computer's framework-only routes stays in REA: no result, error or log carries it. Code running in the computer as its user can still read it, which is acceptable for a local build.
See the specification for scope and verification.