Loading fifty tool definitions into a context window is expensive and, past a point, counter-productive. The alternative Glove ships is one eval tool: expose the agent's capabilities as functions in a tiny sandboxed interpreter, and let the model discover, call and compose them by writing programs.
Three surfaces, one catalog. glove-js, glove-python and glove-lisp all consume the same ToolFn catalog from glove-scratchpad, so a set of functions mounts on any of them unchanged. Pick the language your models are most fluent in.
| Package | Tool | Bet |
|---|---|---|
glove-js | execute_js | JavaScript — the most-represented language in training data |
glove-python | execute_python | Python — what models reach for when the task is data manipulation |
glove-lisp | execute_lisp | A Clojure-flavored Lisp — plus the scratchpad's staged-effect contract |
The scratchpad work showed that folding 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 rather than round-tripping every intermediate through its context window. What each surface keeps from that work:
search("open pull requests"), or browses servers() → fns("github") → describe("name"). The same tiers also 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.const prs = github.list_pull_requests() stores the rows in the REPL and echoes only a summary; the model then works with prs.length and prs.slice(0, 5).pnpm add glove-js glove-scratchpad # or glove-python / glove-lispimport { JsSession, mountJs } from "glove-js";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp";
const session = JsSession.create();
// A whole MCP server becomes functions: github__list_pull_requests, …
session.registerAll(await fnsFromMcp(githubConn));
mountJs(agent, { session }); // folds execute_js + discovery tools, primes the promptNow the model works entirely in JavaScript through one tool:
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.
A capability is a ToolFn: a name, an optional input schema (JSON Schema or Zod), and a call. There are no columns, no pushdown keys and no volatility classes to declare — which is exactly what makes this the right surface when the tools are unknown up front, like an arbitrary MCP server discovered at runtime.
import { defineFn, fnFromTool } from "glove-scratchpad";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp";
import { z } from "zod";
// A whole MCP server → functions
session.registerAll(await fnsFromMcp(conn));
// An existing Glove tool → a function
session.register(fnFromTool(myTool));
// 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 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 on the JS/Python surfaces; the write verb is the function. (The Lisp surface adds staging — see below.)
A deliberately small subset — the JavaScript a model reaches for when it thinks “transform this data”, and nothing else: const/let, arrow functions, template literals, destructuring, spread, optional chaining, the usual control flow, the array and string methods, Object.*, Math, JSON, Set/Map/Date/RegExp, and captured console.log. Tool calls are async functions whose promises resolve automatically, so await is optional.
Rejected with a targeted message rather than gibberish: class, import/require, eval, Function, this, prototypes, fetch, for…in, var, in/instanceof, generators.
prs = github.list_pull_requests(state="open")
stale = [p for p in prs if p["age_days"] > 30]
"all fresh" if len(stale) == 0 else f"{len(stale)} stale"The Lisp surface is built on the scratchpad's ResourceTable contract as well as the function catalog — so it can stage several outbound effects, preview them, and commit or discard as a real dry run:
;; discover
(tables)
(describe :github_pull_requests)
;; keep big intermediates in the REPL, out of context
(def prs (github_pull_requests)) ; echoes {:defined "prs" :count 320}
(frequencies :state prs)
;; BRANCH in one call — decide-and-act
(if (empty? (pagerduty_incidents {:urgency "high" :status "triggered"}))
(insert! :slack_messages {:channel "ops" :text "All clear."})
(insert! :emails {:to_addr "oncall@acme.io" :subject "Incidents live"}))
;; stage several effects, preview, then fire — or discard
(stage (insert! :emails {:to_addr "a@b.io" :subject "one"})
(insert! :emails {:to_addr "c@d.io" :subject "two"}))
(commit!) ; or (rollback!)parse → validate → run. The parser builds 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 and per loop back-edge, so while (true) {} cannot hang), a recursion-depth cap, and AbortSignal support.
Every member read and method call goes through a sandbox boundary that blocks the escape keys (constructor, __proto__, prototype, call/apply/bind) and exposes a fixed allowlist — a program cannot climb a constructor chain back to the host.
The eval tool ships three interchangeable framings, chosen at mount time. The runtime is identical — only the tool name and the primed preamble change:
mountJs(agent, { session }); // execute_js (default)
mountJs(agent, { session, frame: "program" }); // execute_js_program
mountJs(agent, { session, frame: "workflow" }); // execute_js_workflowThe bet: 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 carrying the task start to finish, and demotes cross-call persistence to a retry-only recovery aid. program is the half-step, so a benchmark can separate the name alone from the full reframing.
Result shapes warm lazily — a function's row type is sampled the first time it is described, not for the whole catalog at mount, and surfaces as a TS-like type in describe(...):
sentry__list_issues(…) → { …, count: number,
status: "unresolved"|"resolved"|"ignored" }[]discovery: "full" primes every signature up front for small catalogs; "auto" picks per size. Result-shape discovery is what closed the weak-model gap in the live A/B — the JavaScript arm moved 78% → 90% → 97% over two hardening batches, above Lisp (95%) and SQL (92%), for a modest peak-context increase.