glove-working-environment gives an agent a filesystem and a script runtime instead of a fixed menu of tools. The model does not pick from a list of document actions — it writes code against your files, saves that code, and runs it again next time.
Where the database emulator is a stateless-per-call REPL, this is a place where state accumulates. A tool call ends when it returns. A script stays.
import { createWorkingEnvironment, mountWorkingEnvironment } from "glove-working-environment";
import { documents } from "glove-env-documents";
import { spreadsheets } from "glove-env-spreadsheets";
const env = await createWorkingEnvironment({
stdlib: [documents(), spreadsheets()],
limits: { runTimeoutMs: 60_000 },
});
// Structural — no glove-core dependency in the package.
mountWorkingEnvironment(agent, { env });
await env.mount("./report.pdf", "/inbox/report.pdf"); // door in
const files = await env.export("/out/**"); // door outexamples/document-desk: chat on the left, the code the agent is writing on the right, and the filesystem you are both working in behind a button.One sandboxed, in-memory virtual filesystem holds inputs, scripts, intermediates, outputs, docs and history. Nothing in a script can reach the network, the host filesystem, or a process — not by policy, but by construction: scripts execute in a vm context whose scope contains only what the host injected.
| Path | Written by | What it is |
|---|---|---|
| /inbox | host | Inputs you mounted. The model's starting point. |
| /scripts | model | Its persistent library. Each .js gets a generated .d.ts sibling; `ls /scripts` is the capability catalogue. |
| /skills | environment | Worked recipes, materialised at startup. Read-only. |
| /std | environment | One directory per module — its types and README. Read-only. |
| /tmp | model | Intermediates and spilled run output. |
| /out | model | Deliverables. This is what the host exports. |
| /.env | environment | Orientation and bookkeeping. |
The model-facing surface is a closed verb set: write_file, edit_file, read_file, ls, grep, describe, rm, mv, cp, run_script, run_tests, checkpoint, undo, redo, history. Every script is validated at write time — it must export default async function (args), and its imports must resolve — so a broken script is caught before a run is spent on it.
Scripts run in worker threads. That is not an optimisation: worker.terminate() is the only mechanism that stops a compute-bound script regardless of what it is doing, which is what makes the wall-clock limit real rather than advisory.
The filesystem is pluggable — filesystem takes any Vfs. Three ship, and which you want depends on why you are asking.
| Backend | Use when | Cost |
|---|---|---|
| inMemoryFs() | Default. The tree is a data structure, so snapshot and restore are near-free. | heap, per environment |
| hostDirectory(dir) | Point the agent at a real corpus. Copy-on-write — nothing on disk changes until commit(). | reads fall through to disk |
| cachedRemote(store) | The tree outgrows the heap, or other systems must read the files directly. | one round trip per file read/write |
Orthogonal to the backend: readOnlyPaths fences directories the agent can read but never mutate — the rule the environment already applies to /std, made configurable.
const env = await createWorkingEnvironment({
filesystem: hostDirectory("./project"),
readOnlyPaths: ["/src"], // read and grep the source; write only elsewhere
});
await env.mount("./handbook.pdf", "/src/handbook.pdf"); // the host door stays openEnforced at the core mutation gateway, so it binds the model verbs, scripts going through env:fs, and adapters alike — and the refusal names the zone and the fix (copy to /tmp, work on the copy). The orientation file announces each zone up front, so the model learns the boundary by reading, not by being refused.
For plain persistence across restarts, none of those is the answer — snapshot() is. It serializes the whole tree, empty directories and mtimes included, to one object:
await s3.put(`sessions/${id}.json`, JSON.stringify(await env.snapshot()));
// …later
const env = await createWorkingEnvironment({
filesystem: fromSnapshot(saved),
stdlib: [documents()],
});One round trip per session instead of one per file, and atomic. Reach for cachedRemote only when you need the files to exist as individual objects.
You supply the backend — four methods (get, put, delete, list), which is all S3, GCS, R2 and Azure Blob have in common, and why this package depends on none of them.
const env = await createWorkingEnvironment({
filesystem: await cachedRemote(myStore, { prefix: `sessions/${id}/` }),
});cachedRemote rather than remote for a reason. The Vfs contract has three whole-tree operations and they are not cold paths — totalSize() runs on every write, and files() backs glob, grep, recursive rm and checkpoint fork. Passed straight through, that is a full bucket LIST per write.So the structural index — paths, sizes, mtimes, which directories exist — stays in memory and is maintained on every mutation. Only file content crosses the network: files(), list(), stat(), exists() and totalSize() cost zero round trips. The index is updated only after the store confirms a write, so a failed put leaves it honest rather than claiming a file that is not there.
One thing it does not do: distributed locking. The environment serializes its own mutations within a process, but two hosts on one prefix would race on version rings and run history. Give every session its own prefix — which also makes cleanup a single delete-by-prefix.
This is the decision to get right. The shape of the library picks the route — and the wrong route fails quietly rather than loudly.
| Library shape | Route | Call style |
|---|---|---|
| Does I/O — reads or writes files, calls out | defineAdapter | async |
| Stateful builder — new X(), chained mutation, terminal save | defineBuilder / defineBuilders | async |
| Pure computation — no I/O, no state | definePureModule | synchronous |
| Not a library at all — an MCP server, a Glove tool, any async fn | defineTools | async |
definePureModuleAdapter calls cross a thread, so every adapter binding is asynchronous. That is right for I/O and silently wrong for a library whose whole idiom is synchronous.
sumBy(rows, 'n') without await returns a stringified promise — and the run reports success. Inside a synchronous callback, keys.map(k => camelCase(k)), there is no correct spelling at all.definePureModule imports the package inside the worker and binds it directly into the vm context, so calls never leave the thread and stay synchronous. Sync is the forgiving direction: await on a plain value is a no-op, while a missed await on a promise is silent garbage.
import { definePureModule } from "glove-working-environment";
definePureModule({
name: "lodash",
from: "lodash",
description: "Lodash utilities for shaping data.",
pick: ["groupBy", "sumBy", "orderBy", "uniqBy", "camelCase", "cloneDeep"],
})
// …and the model writes ordinary lodash, with no wrong syntax available:
// import { groupBy, sumBy } from 'env:lodash';
// const byRegion = groupBy(rows, 'region');
// const total = sumBy(rows, r => r.revenue);That is the entire integration. No bundling step, no hand-written types, no VFS bytes. Generated at creation: /std/<name>/index.d.ts with accurate synchronous declarations, and a README carrying the exact import line.
pick is the sandbox boundary, not a convenience. Picked functions run in the worker's realm, outside the vm. Never pick a string-to-code member — _.template compiles with Function(source), which is arbitrary code execution outside the sandbox. Prototype members are refused at definition time; every other name is verified against the real module when the environment is created, so a typo fails there rather than as undefined in a script.defineBuilderA builder API cannot be deep-copied across a thread — a Proxy whose behaviour lives in its traps has no own keys, so a copy of it is {}. Instead the calls are recorded in-context as a flat op list and replayed host-side when a terminal call fires. Chained calls, property reads and passing one node into another all survive.
This is how glove-env-slides and glove-env-spreadsheets expose pptxgenjs and exceljs unchanged — the model writes the library's real API, the one it already knows from training.
defineToolsThe three routes above wrap libraries. This one wraps whatever the host already has as a tool — an MCP server, a Glove tool, a plain async function — and turns it into a module scripts import.
import { defineTools } from "glove-working-environment";
import { fnsFromMcp, fnFromTool, defineFn } from "glove-scratchpad/fns";
const env = await createWorkingEnvironment({
stdlib: [
documents(),
slides(),
defineTools({ name: "github", fns: await fnsFromMcp(gh) }), // a whole MCP server
defineTools({
name: "workspace",
fns: [fnFromTool(searchInbox), defineFn({ name: "today", handler: () => todayIso() })],
docs: "Tokens belong to the workspace bot. `since` is inclusive.",
}),
],
});import { list_pull_requests } from 'env:github';
import { create } from 'env:slides';
export default async function () {
const prs = await list_pull_requests({ repo: 'you/repo', since: '2026-08-01' });
const byAuthor = Object.groupBy(prs, (p) => p.author);
await create('/out/week.pptx', {
slides: Object.entries(byAuthor).map(([author, items]) => ({
title: author, bullets: items.map((p) => p.title),
})),
});
return `${prs.length} PRs from ${Object.keys(byAuthor).length} people`;
}Two hundred pull requests, a thousand emails, a year of calendar events — the model writes the loop that reduces them and only the last line comes back. And because the capability lands beside env:documents and env:slides, "a PDF of all my emails" stops being two systems and becomes one script.
git log mounted as env:github, z-ai/glm-4.6 produced the deck in 18 turns for $0.026 — wrote a script, got one export name wrong and fixed it from the error, pulled 100 commits into a variable, grouped them into six themes inside the script, built the .pptx, checked it, and handed it over. Only the summary reached the context window.The ToolFn shape is declared structurally, so glove-scratchpad/fns' builders drop straight in while this package keeps its zero dependencies. Anything matching { name, description?, inputSchema?, call(args) } qualifies. Types and a README are generated from the input schemas — enums arrive as unions, not as string.
The core is zero-dependency. Heavy format libraries ship as separate glove-env-* packages, each mounting as one env: module. Mount as many as the work needs.
| Package | Module | What the agent gets |
|---|---|---|
| glove-env-documents | env:documents | PDF and DOCX from one spec; merge, split, stamp, extract text. Full docx API via builder. |
| glove-env-spreadsheets | env:spreadsheets | .xlsx as plain-JSON records with paging; CSV both ways; the exceljs Workbook for styling. |
| glove-env-images | env:images | resize, convert, crop, rotate, composite, contact sheets — without decoding pixels into context. |
| glove-env-slides | env:slides | .pptx generation and read-back; the pptxgenjs builder API unchanged. |
| glove-env-zip | env:archives | zip, tar, tar.gz both directions. No dependencies — node:zlib only. |
| glove-env-media | env:media | audio/video via bundled ffmpeg — describe, thumbnail, frames, clip, transcode. |
| glove-env-render | env:render | rasterize a PDF, deck or Word file to page PNGs — so the agent can look at what it made. A .pptx works with nothing installed, via a layout schematic. |
| glove-env-motion | env:motion | a React scene to an mp4, GIF, PNG frames or a still — deterministically. React Native Reanimated scenes render unchanged. |
Every one of them leads with describe(path): a summary of a file that costs a few dozen tokens and never pulls the bytes into the context window. It is the orientation verb, and the environment routes the generic describe to whichever module recognises the format by its magic bytes.
Everything above verifies by reading text back. That finds a wrong number and misses a table running off the page, a chart with no bars, or a title overlapping its subtitle — the defects a person notices in the first second.
Wire a vision model and a view_image verb appears. Leave it out and the verb is absent from the tool set entirely: an agent is never shown a capability that would fail on use.
import { render } from "glove-env-render";
const env = await createWorkingEnvironment({
stdlib: [documents(), render()],
vision: {
// One function, not a model adapter — so this package keeps its zero
// dependencies and works with whatever you already have.
async describe({ bytes, mediaType, prompt }) {
return await myVisionModel(bytes, mediaType, prompt);
},
},
});The verb takes a path and a question, and rasterizes documents on the way — so checking a PDF is one call rather than render-then-look:
view_image({
path: '/out/report.pdf',
prompt: 'This should list four regions with a total. Name every region and
figure you can see, and say whether any text is cut off or overlapping.'
})
// A later page or slide, still without a render step:
view_image({ path: '/out/deck.pptx', page: 3, prompt: 'Is this slide blank?' })An empty prompt is refused with an example. "Describe this image" costs the same as a real question and answers far less: say what you expected, then ask what is actually there.
Any adapter can be the renderer — declare renders alongside a render(input, outDir, opts?) binding. glove-env-render does it for PDFs and images with no system dependency, and for Office formats through headless LibreOffice.
A .pptx is the exception that needs nothing installed. With no LibreOffice it is drawn from its own OOXML geometry as a layout schematic — every shape's real frame and real text, to scale, with no theme, fonts or charts. The result carries approximate: true and the image is captioned as one, so it cannot be mistaken for a render. It answers the positional questions, which is most of what goes wrong: what is off the slide, what overlaps, what came out empty.
glove-env-motion is the adapter for output that is not a document. The agent writes a React component; a video, an animated GIF, PNG frames or a still image comes out. The browser is the drawing surface, which is why one capability covers an animated explainer, a title card and a chart image — it is env:motion, not env:video.
import { motion, MOTION_LIMITS } from "glove-env-motion";
const env = await createWorkingEnvironment({
stdlib: [motion()],
limits: MOTION_LIMITS, // renders need more than the 30s default script budget
});React, the Babel toolchain and ffmpeg ship with the package. An installed Chrome, Edge or Chromium is found automatically on macOS, Windows and Linux; only a bare container needs npx playwright-core install chromium. Add react-native-reanimated and react-native-web and React Native motion code renders unchanged, worklets included.
The hard part is that a browser animation is a function of wall-clock time, so screenshotting the same scene twice gives two different pictures. Time is therefore replaced, not measured: before any scene code runs, requestAnimationFrame becomes a queue nobody drains except the renderer, and performance.now() returns a number it sets. One advance is one frame. Two independent runs of the same 60-frame scene produce byte-identical PNGs, which is what makes a re-render after an edit a real diff.
Scenes come in two shapes and the caller picks neither. A useFrame() scene is a pure function of the frame number; a Reanimated scene is driven by its own clock. The renderer advances both signals every frame, and each is inert for the other kind — so any scene animates with no configuration, and frame f is always t = f/fps.
Two things are configuration rather than code, and both fail loudly on purpose. Renders are slow — a frame is a screenshot — so a render is refused before it starts if the environment's runTimeoutMs cannot cover it, naming the exact limit to set rather than timing out halfway. And glove-motion-doctor answers "can this host render?" from a shell, with the fix command on every failing row; the same checks feed capabilities() at runtime and the generated /std/motion/README.md the agent reads.
One thing worth knowing before you preview a render: a Chromium built without proprietary codecs — including the one playwright-core installs — cannot decode H.264, and shows a black rectangle with working controls rather than an error. The file is fine; Chrome, Edge, Safari and Firefox all play it. Render .webm if your viewer is that kind of Chromium.
Writing a file to /out makes a file. present delivers it — and the distinction earns its keep because /out accumulates. By the end of a task it holds drafts, a superseded version, and the spreadsheet that fed the report. Only the agent knows which of those was the answer, so without an explicit hand-off the host is left guessing from filenames and timestamps.
const env = await createWorkingEnvironment({
onPresent: async ({ name, bytes, mediaType, caption }) => {
await sendToUser({ name, bytes, mediaType, caption }); // upload, attach, stream — your call
},
});
// The agent then calls:
// present({ path: '/out/q2-review.pptx',
// caption: 'Q2 review, 8 slides — revenue by region, East flagged as the outlier.' })Wired on the same terms as view_image: no receiver, no verb. The path must be under /out — presenting from /tmp would ship an intermediate and presenting from /inbox would echo the person's own upload back at them as work. The refusal names the fix, and making the agent copy the file first is the check: it forces a decision about what is finished.
The caption is required, and an empty one is refused with an example. The person reads it in place of the filename, and report.pdf is not a description. A matching /skills/delivering.md appears alongside the verb, and stays absent without it — a recipe for a capability that is not offered is how an agent learns to hallucinate the call.
import { defineAdapter } from "glove-working-environment";
export const invoices = () =>
defineAdapter({
name: "invoices",
description: "Read and reconcile invoices from the billing system.",
types: `export function fetch(id: string): Promise<Invoice>;`,
docs: "# env:invoices\n\nWorked examples go here.",
skills: [{ name: "reconcile", summary: "…", body: "…" }],
create(vfs, ctx) {
return {
async fetch(id: string) { /* … */ },
};
},
});create is the capability boundary, and it is called twice — once read-only, to validate scripts at write time without letting that validation perform side effects. Every function it exposes is wrapped so failures read env:<name>.<fn>: …, and arguments arrive deep-copied as host-realm values.
Test with glove-working-environment/testing. createAdapterTestEnv(adapter) returns { env, fs, script(), runScript(), audit() }, and audit() fails the build when your types and the real bindings disagree in either direction — a declared function that does not exist, or an undeclared one that does.
The agent must run server-side. This is the inverse of the usual glove-react arrangement: createChatHandler is a model proxy for tools that execute in the browser, and these tools cannot. Run processRequest in a route and forward the agent's own event stream as SSE.
Two bundler traps, both of which surface far from their cause:
// next.config.ts
const EXTERNAL = [
"glove-working-environment",
"glove-env-documents",
"glove-env-spreadsheets",
"glove-env-images",
"glove-env-slides",
"glove-env-zip",
"glove-env-render",
"glove-env-motion",
"sharp", // native
"pdfjs-dist", // ships its own worker and dislikes being rewritten
"playwright-core",
];
const nextConfig: NextConfig = {
// 1. The worker pool locates its entry relative to its own module URL.
// Bundled, that URL points into a Next chunk and the worker is not
// beside it — failing at the first run_script, not at build time.
serverExternalPackages: EXTERNAL,
// 2. MONOREPO ONLY. Next matches the list above against the RESOLVED path,
// and resolves with symlinks:true hardcoded. A pnpm workspace link
// resolves to ../../packages/<name> — no node_modules segment, no match,
// bundled anyway. Symptom: "Can't resolve './worker-dev.mjs'".
webpack: (config, { isServer }) => {
if (!isServer) return config;
const existing = Array.isArray(config.externals) ? config.externals : [];
config.externals = [
({ request }, callback) =>
request && EXTERNAL.some((p) => request === p || request.startsWith(`${p}/`))
? callback(undefined, `module ${request}`)
: callback(),
...existing,
];
return config;
},
};Measured over 90 runs — five document/data scenarios, three open models, six repetitions each — with programmatic checks and a strong-model judge. The harness is examples/analyst-desk.
Three findings worth carrying into your own build:
Guessed imports are the number one failure. Not misused APIs — misremembered import lines. That is why /skills/imports.md exists, why the environment corrects wrong import names at write time rather than at run time, and why your system prompt should point at /skills/README.md first.
Exposing the real library beat a simplified wrapper. The scenario that required the genuine pptxgenjs surface scored highest of all five. A model already knows these libraries; a bespoke wrapper throws that knowledge away and asks it to learn yours.
Never let a large document into the context window. An 80-page report is roughly 200KB of text against an 8KB response cap. Extract to a file and grep it — searching is not an optimisation here, it is the only thing that works.
It is not a container. Scripts cannot reach the network, the host filesystem or a process, but a picked pure-module function runs in the worker's realm — the pick allowlist is doing real security work, and a string-to-code member defeats it.
It is not a generate-and-evaluate loop. The environment gives the model a place to work and honest errors when it gets something wrong; deciding whether the output is good, and retrying if not, is the host's job.
The filesystem is in-memory by default, so it is host heap, per environment. A host running many agents in one process must size limits.maxBytes accordingly and call env.close() when a session ends to release its worker threads.
For a stateless-per-call surface over a fixed set of capabilities, see the database emulator — same goal (one tool instead of dozens), different trade: SQL over resources rather than a filesystem the model builds on.