glove-scratchpad
v0.5.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]);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
