glove-scratchpad
v3.0.0
Published
A database emulator for LLM tool use — expose an agent's capabilities as a relational database it queries with one execute_sql tool. Resources become tables, WHERE pushes arguments down, information_schema is discovery, transactions stage outbound effects
Readme
glove-scratchpad
A database emulator for LLM tool use. Instead of loading dozens of tool
definitions into the context window, expose an agent's capabilities as a
relational database it queries with a single execute_sql tool. The model
already knows SQL — fluently, at every model size — so it discovers, invokes, and
composes capabilities by writing queries.
The idea (from "SQL Is the Future"): resources become tables. A resource is
an entity/data type — github_pr, linear_issue, emails, time, images —
and its CRUD verbs map to (possibly different) underlying tools:
-- discover what you can do
SELECT table_name FROM information_schema.tables;
SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'tasks';
-- invoke a tool by querying its table; push arguments through WHERE
SELECT id, name FROM tasks WHERE due_date = (SELECT tomorrow FROM time);
-- compose across services in one statement — no intermediate rows in context
INSERT INTO notion_page (title, body)
SELECT title, body FROM github_pr WHERE merged = true AND base = 'main';
-- stage an outbound effect, preview it, then commit (or roll back — a dry run)
BEGIN;
INSERT INTO emails (to_addr, subject, body) VALUES ('[email protected]', 'hi', 'yo');
-- inspect what is staged, then:
COMMIT;It is, at heart, a SQL interpreter: every statement is parsed and inspected
before any tool runs. That buys discovery (information_schema), composition
(joins / INSERT … SELECT), preview (EXPLAIN, transactions), and a real
security surface (a syntax tree you can reject) — for free, because the database
already solved them decades ago.
pnpm add glove-scratchpad
# zero runtime dependencies — the query engine (glove-sql) is bundled.
#
# OPTIONAL: a full Postgres dialect (WASM) instead of the bundled subset:
pnpm add @electric-sql/pglite
# OPTIONAL: bridge MCP servers in as tables:
pnpm add glove-mcpQuick start
import { Database, resourceFromTool, defineResource, mountDatabase } from "glove-scratchpad";
import { z } from "zod";
const db = await Database.create({ policy: { writes: true } });
// A read-only tool → a one-row `time` table. Columns come from a Zod schema
// (z.date() → timestamptz), so the shape is one source of truth.
db.register(resourceFromTool(getTimeTool, {
name: "time", volatility: "stable",
schema: z.object({ now: z.date(), tomorrow: z.string() }),
}));
// A search tool → a `web` table whose required `query` column is a pushed-down argument.
db.register(resourceFromTool(searchTool, {
name: "web", volatility: "volatile",
schema: z.object({ title: z.string(), url: z.string() }),
}));
// Fold the single tool + prime the model to discover → invoke → act → stage.
mountDatabase(agent, { db });Now the model works entirely in SQL through execute_sql (and explain_sql).
For a runnable, no-API-key tour of every property below, see
examples/scratchpad-agent (pnpm scratchpad:db).
The moving parts
| Concept | Code |
| --- | --- |
| Resource (a table) | ResourceTable (glove-scratchpad/db) |
| Author a resource | defineResource({ name, schema, keys?, volatility, select?, insert?, update?, delete? }) — a Zod schema is the columns AND the end-to-end row type (or pass columns directly) |
| One tool → one resource | resourceFromTool(tool, { name, volatility, schema \| columns, op? }) |
| The interpreter | Database — execute(sql) / explain(sql) |
| The single agent tool | mountDatabase(glove, { db }) → folds execute_sql + explain_sql |
| MCP servers → tables | mcpResources / mountMcpDatabase (glove-scratchpad/mcp) |
| Query engine | glove-sql (default) or PGlite (glove-scratchpad/pglite) |
How a query runs
The query engine (glove-sql) is synchronous; resources are
async and effectful. So Database.execute cannot hook resolution inside the
engine — it pre-resolves:
- Parse the SQL (the same parser the engine executes — one grammar).
- Gate it: a statement-kind whitelist, read-only by default,
CREATE/DROPrefused, multi-statement only as aBEGIN … COMMIT/ROLLBACKscript. The model's capability is bounded by a syntax tree you inspect. - Collect every relation the statement references (FROM, JOINs, subqueries,
CTE bodies,
INSERT … SELECTsource) and classify each as a resource or not. - Push down the
WHERE/JOIN-ONequalities scoped to each resource — these are arguments, not just filters (Steampipe's required-key model); missing required keys are a clear error. - Resolve each resource exactly once (per the volatility model), materialize its rows into the engine, run the now-synchronous query, then tear down the ephemeral tables.
Resolving once, up front, is also what makes the volatility guarantee hold: the engine evaluates FROM-resolution lazily and repeatedly (once per correlated-subquery row), so an inline async hook would invoke an effectful tool N times. Pre-resolution invokes it once.
Resources as tables
A resource is an entity with columns and any subset of CRUD verbs, each wired
independently. Define it with a Zod schema and one object is your columns AND
your end-to-end row type — the schema flows into every resolver, so select
returns rows of it, insert takes them, update's set is a partial, and
bindings.one("col") autocompletes the schema's column names:
import { z } from "zod";
const githubPr = defineResource({
name: "github_pr",
volatility: "stable",
schema: z.object({
number: z.number().int().describe("PR number"), // an API argument (see keys)
title: z.string(),
merged: z.boolean(),
}),
keys: ["number"], // required WHERE-pushdown key(s), typed to the schema
select: (b) => listPrs({ number: b.one("number") }), // SELECT → a list/get tool
insert: (rows) => createPr(rows[0]), // INSERT → a create tool (rows typed to the schema)
update: (set, b) => updatePr(b.one("number"), set), // UPDATE → an update tool (set: Partial<row>)
delete: (b) => closePr(b.one("number")), // DELETE → a close tool
});Zod field types map to Postgres types (z.number().int() → bigint,
z.number() → double precision, z.boolean() → boolean, z.date() /
z.iso.datetime() → timestamptz, nested objects/arrays → jsonb);
.describe(...) becomes the column description (where authors put the enum /
allowed-value hints the model reads); .meta({ pgType: "…" }) forces an exact
type. Prefer the schema, but a raw columns: [{ name, type, requiredKey? }] list
still works when you'd rather write the pg types by hand (columnsFromZod is
exported if you want the mapping standalone).
A read-only time has only select; an emails (send) is insert-only; an
images generator is a select-shaped but volatile function-as-relation
(SELECT url FROM images WHERE prompt = '…' — prompt is an argument). Verb
presence is the capability gate: SELECTing a write-only resource, or writing one
with no writer, is a clear error.
A resolver returns rows shaped by the schema; the interpreter maps them onto the
declared columns (nested values land in jsonb, reachable via -> / ->>).
Required-key columns are auto-stamped from the pushed-down WHERE, so a select
may omit them. DDL comes from the declared columns so the schema is stable
for information_schema even when a call returns zero rows.
Volatility
Every resource declares immutable | stable | volatile (Postgres's model). It
governs caching and protects effectful tools from being called the wrong number
of times:
- immutable — cached for the database's lifetime (pure lookups).
- stable — cached within one
execute(a turn-stable read, e.g.time). - volatile — re-resolved each statement; never cached. A volatile read or write is invoked exactly once per statement no matter how the engine re-evaluates subqueries.
Transactions = preview & staging
A write against a resource is a side-effecting tool call. Inside a transaction it
is staged, not fired — recorded with the exact resolver + arguments it will
invoke. db.preview() (and the staged field on the result) is the approval
surface; COMMIT fires the staged writes in order; ROLLBACK discards them — a
true dry run. This maps cleanly onto approval-gated outbound, with no new
machinery. Writes are off unless the Database is created with
policy: { writes: true }.
EXPLAIN
db.explain(sql) (and the explain_sql tool, and EXPLAIN <stmt> through
execute_sql) runs the pre-pass only — no resolver calls — and reports which
resources a statement will hit, each one's volatility, read/write access, and the
arguments it resolved. Explaining a generate_image query costs nothing.
Discovery is information_schema
There is no separate discovery step. Resources are advertised in
information_schema.tables / .columns (engine-agnostically, via a catalog
callback), so the agent lands in an unfamiliar database, lists its tables,
inspects the relevant ones, and figures out its own capabilities — exactly how
SQL has always done progressive disclosure.
MCP servers → tables (glove-scratchpad/mcp)
Most MCP tools are CRUD over some resource type, so decompose a server into
resources and give each a table. glove-mcp is an optional peer dependency.
import { connectMcp } from "glove-mcp";
import { mountMcpDatabase } from "glove-scratchpad/mcp";
const conn = await connectMcp({ namespace: "github", url });
await mountMcpDatabase(db, conn, {
table: (t) => t.name === "list_pull_requests"
? { name: "github_pr", op: "select", volatility: "stable",
columns: [{ name: "title", type: "text" }, { name: "merged", type: "boolean" }],
rows: (d) => JSON.parse(d as string) }
: null, // skip the rest, or map them too
});
// → INSERT INTO linear_issue SELECT … FROM github_pr WHERE merged = true
// composes two servers in one statement.A read tool (readOnlyHint) defaults to a select resource; others default to a
volatile insert. MCP results rarely carry clean column lists, so declare
columns (and a rows extractor) via table(tool) to make a server's data
genuinely queryable.
Function catalog (glove-scratchpad/fns) — the light alternative
Modeling a capability as a table (columns, required-key pushdown, a volatility
class) is the right shape when the data is worth querying — and too much
ceremony when the tools are unknown up front. An arbitrary MCP server discovered
at runtime has no columns to declare; the table mapping above falls back to a
single useless result column unless you hand-author each one.
The fns subpath is the lightweight path: a capability is a ToolFn — a
name, its own input schema (JSON Schema or Zod), and a call. No columns, no
keys, no volatility. It doesn't run SQL; instead you mount the catalog on a REPL
surface — glove-lisp's function mode or
glove-js — where the model calls the function directly and
composes with the language's own primitives.
import { defineFn, fnFromTool } from "glove-scratchpad/fns";
import { fnsFromMcp } from "glove-scratchpad/fns/mcp"; // optional-peer subpath
// A whole MCP server → functions, no per-tool specs:
const fns = await fnsFromMcp(conn); // github__list_pull_requests, …
// Wrap an existing Glove tool, or author one inline:
const search = fnFromTool(webSearchTool);
const send = defineFn({
name: "email__send",
input: z.object({ to: z.string(), subject: z.string() }),
readOnlyHint: false,
handler: (args) => sendEmail(args),
});
// Then mount on a REPL surface (see glove-lisp / glove-js):
session.registerFns([...fns, search, send]);The same catalog on a working environment
glove-working-environment's defineTools
takes this list too, and mounts it as an importable module rather than a REPL
binding:
createWorkingEnvironment({
stdlib: [documents(), slides(), defineTools({ name: "github", fns })],
});Same catalog, different surface — and the difference is what surrounds it. A
REPL session is stateless per call; the working environment is a filesystem, so
the records a capability returns land next to env:documents and env:slides
and the script that fetches them is also the script that produces the file. "A
PDF of all my emails" is one script there and two systems anywhere else.
That package declares ToolFn structurally rather than importing it, so it
keeps its zero dependencies — tests/fns-working-environment.test.ts here holds
the two definitions together, since neither package's own build would notice
them drifting.
Prefer tables when the data is relational and you want to compose it with
JOINs, aggregate it, or stage writes with BEGIN … COMMIT. Prefer functions
when the catalogue is unknown, heterogeneous, or you just want to call a tool and
branch on its result — a function fires immediately when called; there is no
staging. Both consume the same underlying Glove tools, so the choice is per
agent, not per tool.
| Need | Symbol (from glove-scratchpad/fns) |
|------|--------------------------------------|
| Author a function inline | defineFn({ name, input?, readOnlyHint?, handler }) |
| Wrap one Glove tool | fnFromTool(tool, { name?, readOnlyHint?, parse? }) |
| Bridge a whole MCP server | fnsFromMcp(conn, opts?) (from glove-scratchpad/fns/mcp) |
| A registry | FnCatalog |
| Render a signature / params | fnSignature(fn), describeFn(fn) |
Backends
The manipulation surface is a defined Postgres subset; the backend behind it is
swappable (ScratchpadBackend).
glove-sql(default) — a zero-dependency, pure-JS Postgres-subset engine. Covers the SQL agents write: joins,GROUP BY/HAVING, CTEs, set ops, correlated subqueries, window functions,jsonbaccess, a library of scalar functions, plusinformation_schema,INSERT … SELECT, andUPDATE. Anything outside the subset throws a clear error rather than mis-answering.PgliteBackend(glove-scratchpad/pglite) — embedded Postgres (WASM) for a full dialect.@electric-sql/pgliteis an optional peer.- Bring your own — implement
ScratchpadBackendover real Postgres, SQLite, a remote service.
import { Database } from "glove-scratchpad";
import { PgliteBackend } from "glove-scratchpad/pglite";
const db = await Database.create({ backend: await PgliteBackend.create() });API
const db = await Database.create({ policy?: { writes }, backend?, actor? });
db.register(resource); // or registerAll([...])
await db.execute(sql, { params?, limit?, allowWrites?, signal? }); // → { rows, truncated, touched, staged?, committed?, message? }
await db.explain(sql, { params? }); // → { statementKind, readOnly, relations, staged? } (runs no resolvers)
db.preview(); // staged writes in the open transaction
mountDatabase(glove, { db, prime?, explain?, allowWrites? }); // fold execute_sql (+ explain_sql) and prime the promptWhat this is not
The honest limits (named in the essay):
- Effectful relations are volatile. A
SELECTthat costs money / is nondeterministic is a volatile relation; the interpreter carries a volatility model so the engine can't quietly call it the wrong number of times — but you own declaring volatility correctly. - Atomic conditional composition doesn't reduce. Branching where the next tool depends on a prior tool's output, inside one statement, is imperative-vs-declarative — punt it to the agent loop (query, look, query again).
- Tables are live views, not stored data. Rate limits, pagination, and
partial failure when one service times out mid-
JOINare real and yours to handle in the resolver.
Status
Draft. The default backend covers the SQL subset the emulator and its agents use;
swap in PgliteBackend when a workflow outgrows it. The empirical "how small a
SQL surface covers what fraction of real tool use" question is left open by
design.
License
MIT
