glove-js
v0.3.0
Published
A JavaScript REPL for LLM tool use — expose an agent's capabilities as async functions in a tiny, sandboxed JS interpreter it drives with one execute_js tool. Tools become functions, results are plain data, top-level const/let persist across calls, big va
Downloads
733
Readme
glove-js
A JavaScript REPL for LLM tool use. Instead of loading many tool
definitions into the context window, expose an agent's capabilities as async
functions in a tiny, sandboxed JS interpreter it drives with ONE
execute_js tool. The model already writes JavaScript fluently — so it
discovers, calls, and composes capabilities by writing programs.
It is the JavaScript sibling of glove-lisp: both consume the
same ToolFn catalog, so one set of functions
mounts on either surface unchanged. Pick the language your models are most
fluent in.
import { JsSession, mountJs } from "glove-js";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp";
const session = JsSession.create();
session.registerAll(await fnsFromMcp(githubConn)); // github__list_pull_requests, …
mountJs(agent, { session });Now the model works entirely in JavaScript through execute_js:
const prs = github.list_pull_requests({ state: "open" });
const stale = prs.filter(p => p.age_days > 30);
stale.length === 0
? "all fresh"
: `${stale.length} stale: ${stale.map(p => p.number).join(", ")}`;One call. The rows never enter the model's context — only the answer string does.
Why a REPL (and why JavaScript)
The scratchpad work showed that
folding an agent's capabilities behind ONE code-eval tool beats loading dozens
of tool definitions — on correctness, on context, and on cost — because the
model computes over results in the sandbox instead of round-tripping every
intermediate through its context window. The bet is fluency: the surface
must behave like something the model already knows. glove-lisp bets on
Clojure; glove-js bets on JavaScript — the single most-represented language
in every model's training data.
What the surface keeps from that work:
- One tool, progressive in-band discovery. Nothing is primed by default —
the model discovers capabilities in tiers:
search("open pull requests")jumps straight to matching functions, or it browsesservers()→fns("github")→describe("name")(server list → a server's functions → one function's params + result shape). The same tiers exist as native tools (search_functions/list_servers/list_functions/describe_function), so a weak model can fire them as tool calls and a capable one can script the whole sweep in one program. Those native-tool names are also callable inside the code as aliases of the short builtins —search_functions({ query }),list_functions({ server }),list_servers(),describe_function({ name })all work in-REPL (accepting either the{ … }object form or a bare string), so a model primed on the tool names lands its call whichever way it reaches for discovery. Result shapes warm lazily — a function's row type is sampled the first time it's described, not for the whole catalog at mount. (discovery: "full"primes every signature up front for small catalogs;"auto"picks per size.) - Off-context data flow.
const prs = github.list_pull_requests()stores the rows in the REPL and echoes only a summary; the model then works withprs.length,prs.slice(0, 5),prs.map(p => p.title). - Branch in one program.
if (incidents.length === 0) slack.post(...) else email.send(...)— decide-and-act is ONE call, not a read, a look, and a second call. - Exactly-once effects by construction. A tool call fires when its expression evaluates — there is no planner that might re-run it.
- Persistent session. Top-level
const/letsurvive acrossexecute_jscalls, so the model builds up state without re-fetching. - Bounded output. The value that crosses back into context is structurally elided (arrays past 25 items, strings past 300 chars) with a marker naming the true size.
The language
A deliberately small subset — the JavaScript a model reaches for when it thinks "transform this data", and nothing else:
const/let, arrow functions and function declarations, template literals, destructuring (defaults +...rest), spread, optional chaining (?.).if/else,for…of,for,while,switch,try/catch/finally,throw.- Arrays (
map/filter/reduce/find/some/every/sort/flatMap/slice/…), strings,Object.keys/values/entries/assign/fromEntries,Math,JSON,new Set/Map/Date/RegExp,console.log(captured). - Tool calls are async functions; promises resolve automatically, so
awaitis optional.github.list_pull_requests({ state: "open" })just works.
Not in the language — and rejected with a targeted message, not gibberish:
class, import/require, eval, Function, this, prototypes, fetch,
for…in, var, in/instanceof, generators.
How a program runs
parse → validate → run. acorn parses the full program; a whitelist walk
rejects unsupported constructs before anything executes. Then an async
tree-walking evaluator runs it with a fuel budget (per node + per loop
back-edge, so while (true) {} can't hang), a recursion-depth cap, and
AbortSignal support. Every obj.prop read and obj.method(...) call goes
through a sandbox boundary (members.ts) that blocks the escape keys
(constructor, __proto__, prototype, call/apply/bind) and exposes a
fixed method allowlist — a program can't climb a constructor chain back to the
host.
Function mode: no table modeling
A capability is a ToolFn: a name, an optional
input schema (its own — JSON Schema or Zod), and a call. There are no columns,
no pushdown keys, no volatility classes to declare — which is exactly what makes
this the right surface when the tools are unknown up front (an arbitrary MCP
server discovered at runtime).
import { defineFn } from "glove-scratchpad";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp";
import { JsSession } from "glove-js";
const session = JsSession.create();
// A whole MCP server → functions:
session.registerAll(await fnsFromMcp(conn));
// Or author one inline:
session.register(defineFn({
name: "email__send",
input: z.object({ to: z.string(), subject: z.string() }),
readOnlyHint: false,
handler: (args) => sendEmail(args),
}));A __ in a function name becomes a namespace: github__list_pull_requests
binds both the flat name and github.list_pull_requests. Calling an effectful
function FIRES it immediately — there is no staging or undo (the write verb is
the function).
The moving parts
| Concept | Code |
| --- | --- |
| A capability | ToolFn — the same catalog as glove-lisp's function mode (defineFn / fnFromTool / fnsFromMcp) |
| The interpreter | JsSession — execute(code) returns { value, called, defined, defs, stdout, note } |
| The single agent tool | mountJs(glove, { session }) → folds execute_js and primes the model |
| Discovery | progressive by default — search("…") / servers() / fns("server") / describe("name") as REPL builtins, mirrored by the search_functions / list_servers / list_functions / describe_function tools; discovery: "full" \| "auto" primes signatures for small catalogs |
| The parser | parseProgram(code) — acorn + a whitelist validation walk |
| The sandbox boundary | member access mediated by members.ts (the security-critical file) |
Framing: execute_js vs execute_js_workflow
The eval tool ships three interchangeable framings, chosen at mount time with
frame. The runtime is identical — only the tool NAME and the primed preamble
change:
mountJs(agent, { session }); // frame: "repl" → execute_js (default)
mountJs(agent, { session, frame: "program" }); // frame: "program" → execute_js_program
mountJs(agent, { session, frame: "workflow" }); // frame: "workflow" → execute_js_workflowThe bet (see
benches/scratchpad-bench/FRAME-PAPER.md):
the token "REPL" pattern-matches to an interactive, line-by-line session, so
models degrade the surface back into an incremental tool-call loop — peek at a
row, then run a second program. The workflow framing never says "REPL"; it
frames the call as ONE complete program that carries the task start to finish,
and demotes cross-call persistence to a retry-only recovery aid. program is the
half-step (a rename that drops "REPL" but keeps the priming otherwise neutral),
so a bench can separate the name alone from the full reframing. The default is
repl, so existing mounts are unchanged; jsToolName(frame) and
buildJsPreambleBody(frame) expose the mapping and the framing text.
Status
Draft v0.1 — a fluency exploration alongside glove-lisp. The evaluator subset
and sandbox are covered by unit tests (pnpm --filter glove-js test), and the
sandbox boundary survived an adversarial escape review.
A live A/B (6 models × 10 tasks × 5 arms — see
benches/scratchpad-bench/JS-EXPLORATION.md)
found that execute_js reproduces the SQL/Lisp off-context benefit and is
fully competitive on frontier/mid models, while function mode reaches parity
with the ResourceTable contract. The first run's weak-tail misses were two
fluency gaps — the model calling catalog functions as folded tools, and guessing
result field names the catalog never showed — both closed over two hardening
batches: a preamble that frames execute_js as the only tool, and
result-shape discovery (sampleResultShapes samples each read-only function
once and surfaces a TS-like row type in describe(...) and the catalog, e.g.
sentry__list_issues(…) → { …, count: number, status: "unresolved"|"resolved"|"ignored" }[]).
That took jsrepl from 78% → 90% → 97% — the top arm, above Lisp (95%) and
SQL (92%) — for a modest peak-context increase.
License
MIT.
