Working Environment

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.

typescript
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 out
A full working app is in examples/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.

The tree

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.

PathWritten byWhat it is
/inboxhostInputs you mounted. The model's starting point.
/scriptsmodelIts persistent library. Each .js gets a generated .d.ts sibling; `ls /scripts` is the capability catalogue.
/skillsenvironmentWorked recipes, materialised at startup. Read-only.
/stdenvironmentOne directory per module — its types and README. Read-only.
/tmpmodelIntermediates and spilled run output.
/outmodelDeliverables. This is what the host exports.
/.envenvironmentOrientation 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.

Where the tree lives

The filesystem is pluggable — filesystem takes any Vfs. Three ship, and which you want depends on why you are asking.

BackendUse whenCost
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.

typescript
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 open

Enforced 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:

typescript
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.

Object storage, and why the index matters

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.

typescript
const env = await createWorkingEnvironment({
  filesystem: await cachedRemote(myStore, { prefix: `sessions/${id}/` }),
});
It is 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.

Four routes to expose a capability

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 shapeRouteCall style
Does I/O — reads or writes files, calls outdefineAdapterasync
Stateful builder — new X(), chained mutation, terminal savedefineBuilder / defineBuildersasync
Pure computation — no I/O, no statedefinePureModulesynchronous
Not a library at all — an MCP server, a Glove tool, any async fndefineToolsasync

Pure computation → definePureModule

Adapter 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.

Through an async binding, 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.

typescript
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.

Builder APIs → defineBuilder

A 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.

Capabilities → defineTools

The 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.

typescript
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.",
    }),
  ],
});
A tool call puts its whole result in the context window. A tool call from a script puts the result in a variable. That is the entire argument — and it is the same context discipline the rest of this environment applies to files, applied to capabilities.
javascript
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.

Measured on exactly that request. With this repository's real 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.

Write-time validation cannot fire a real effect. Every script write executes the module's top level against a read-only environment. For a filesystem adapter that is merely wasteful; for a capability it would mean the email goes out when the script is saved. A top-level call is refused with the fix attached — move it inside the default export.

Format adapters

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.

PackageModuleWhat the agent gets
glove-env-documentsenv:documentsPDF and DOCX from one spec; merge, split, stamp, extract text. Full docx API via builder.
glove-env-spreadsheetsenv:spreadsheets.xlsx as plain-JSON records with paging; CSV both ways; the exceljs Workbook for styling.
glove-env-imagesenv:imagesresize, convert, crop, rotate, composite, contact sheets — without decoding pixels into context.
glove-env-slidesenv:slides.pptx generation and read-back; the pptxgenjs builder API unchanged.
glove-env-zipenv:archiveszip, tar, tar.gz both directions. No dependencies — node:zlib only.
glove-env-mediaenv:mediaaudio/video via bundled ffmpeg — describe, thumbnail, frames, clip, transcode.
glove-env-renderenv:renderrasterize 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-motionenv:motiona 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.

Letting the agent see its own work

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.

typescript
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:

javascript
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?' })
Measured against a report carrying two deliberate defects — a row pushed off the right edge and a subtitle overlapping the title — a commodity vision model reported both, without being told what to look for.

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.

Things that move

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.

typescript
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.

Handing the work over

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.

typescript
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.

Authoring your own

typescript
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.

Hosting it in Next.js

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:

typescript
// 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;
  },
};

What makes models succeed

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.

92%
produced a deliverable
64%
≥80% of the facts correct
54%
fully correct, every check
15/18
on the scenario needing a real library API — the highest in the suite

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.

What this is not

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.