A step-by-step guide to writing JavaScript verbs for the rag-eval scripting runtime, from first principles to working database queries, file exports, and HTTP servers.
The goal of this chapter is to teach you how to write scripts for the rag-eval-js runtime. By the end, you will understand not only the mechanics of registering a verb and running a query, but also why the system is structured the way it is, what each module does, and how to combine them into useful corpus exploration tools. You will be able to open the SQLite database from JavaScript, inspect documents and chunks, write files to disk, start an HTTP server, and parse Markdown with a Go-backed parser.
The rag-eval-js binary is not a generic JavaScript engine. It is a custom build generated by xgoja from a declarative specification (cmd/rag-eval/xgoja.yaml). This specification selects which Go modules the runtime exposes to JavaScript, which commands the binary provides, and which JavaScript verb files are embedded into the binary itself. Understanding this build step is essential because every time you add or modify a script, you must rebuild the binary before the changes take effect.
The rag-eval system already has a Go CLI and a React web UI. Why add JavaScript?
The answer lies in the nature of corpus exploration. When you are investigating a RAG pipeline, you often need ad-hoc questions answered: "How many documents in the TTC source are still pending?", "What is the average chunk token count per strategy?", "Export all chunked documents to JSON so I can compare them with another pipeline." Writing a new Cobra subcommand in Go for every such question is slow. Rebuilding the React UI to surface a new view is slower still. JavaScript, backed by the same SQLite database, sits in the middle: fast to write, fast to run, and capable of using the full power of SQL when needed.
The scripting layer is also the natural place to prototype. A verb that proves useful can later be promoted to a Go command or a web view. A verb that is only needed once can be discarded without leaving dead code in the Go tree. This is the principle behind jsverbs: they are first-class CLI commands, but they are written in JavaScript and embedded at build time.
Before writing any code, understand the three layers:
xgoja reads xgoja.yaml and generates a Go program that imports selected provider packages, registers them as require-able modules, and compiles everything into a single binary (dist/rag-eval-js). The binary is self-contained: it carries the JavaScript standard library (via goja), the native Go modules, and the embedded jsverbs files.
xgoja.yaml ──► generated Go ──► go build ──► dist/rag-eval-js
│ │ │ │
▼ ▼ ▼ ▼
declares wires modules compiles self-contained
packages, into goja native + binary with
modules, runtime JS + verbs embedded verbs
commands,
verbs
When you run ./dist/rag-eval-js explorer sources, the binary starts a goja runtime, initializes the modules, loads the embedded jsverbs/explorer.js, finds the sources verb, parses the CLI flags, and calls your JavaScript function. The function runs with full access to the database and filesystem.
A verb is a JavaScript function registered with __verb__(). It receives the CLI flags as arguments and returns a plain object (or array) that Glazed formats as table, JSON, YAML, etc. The verb author does not handle CLI parsing, flag validation, or output formatting. Those are handled by the framework.
Every file in cmd/rag-eval/jsverbs/ is a separate package. The file must follow this structure:
__package__({ name: "myPackage", short: "What this package does" });
const db = require("db");
function myVerb(database) {
db.configure("sqlite3", database || "data/rag-eval.db");
return db.query("SELECT * FROM sources");
}
__verb__("myVerb", {
short: "List all sources",
fields: {
database: { type: "string", default: "data/rag-eval.db", help: "SQLite path" }
}
});
__package____package__() declares the package name. It becomes the Cobra command group under which all verbs in the file are nested. In the example above, the CLI path would be rag-eval-js myPackage my-verb.
requirerequire("db") loads the native database module. The available modules are determined by xgoja.yaml. For rag-eval-js, the standard set is:
| Module | What it provides |
|---|---|
db | SQLite query and exec |
fs | File read, write, mkdir, stat |
express | HTTP route registration |
markdown | Parse, render, walk, validate Markdown |
sanitize | YAML and JSON repair ( |
extract | Structured-data candidate extraction |
yaml | YAML parse and stringify |
path | Path manipulation |
The function body is where you write your logic. It receives arguments in the order they appear in the fields descriptor. If a field has argument: true, it becomes a positional argument. Otherwise it is a flag.
__verb____verb__() registers the function. The first argument must match the function name exactly. The descriptor object defines:
short: Help text for the command.fields: A map of field definitions. Each definition can specify type, default, help, argument, and more.Return values are automatically collected by Glazed. You do not call console.log unless you want side-effect output in addition to the structured result.
The db module is the most important module for corpus scripts. It exposes three functions: configure, query, and exec.
configuredb.configure("sqlite3", "data/rag-eval.db");
This opens a connection to the SQLite file. The connection persists for the lifetime of the verb invocation. If you call configure again with a different path, the old connection is closed and a new one is opened.
A common pattern is to wrap this in a helper:
const DEFAULT_DB = "data/rag-eval.db";
function openDatabase(database) {
const path = database || DEFAULT_DB;
db.configure("sqlite3", path);
return path;
}
queryconst rows = db.query("SELECT id, name FROM sources WHERE type = ?", "filesystem");
query takes a SQL string and a variadic list of arguments. Arguments are automatically flattened, so you can pass an array or individual values. The result is an array of plain JavaScript objects. Column names become object keys.
Because rag-eval uses database/sql with the mattn/go-sqlite3 driver, all standard SQLite features are available: PRAGMA, json_extract, window functions, CTEs, and full-text search if the sqlite3 extension is compiled in.
execconst result = db.exec("UPDATE documents SET status = ? WHERE id = ?", "chunked", docId);
console.log(result.rowsAffected);
exec runs statements that do not return rows. It returns an object with success, rowsAffected, and lastInsertId.
Real scripts rarely run a static query. They build SQL dynamically based on user input. Here is the pattern used throughout the explorer.js package:
function documents(database, sourceId, status, limit, offset) {
openDatabase(database);
const n = limit || 20;
const off = offset || 0;
let sql = `
SELECT d.id, d.source_id, s.name AS source_name,
d.title, d.status, d.word_count
FROM documents d
LEFT JOIN sources s ON s.id = d.source_id
`;
const conditions = [];
const args = [];
if (sourceId) {
conditions.push("d.source_id = ?");
args.push(sourceId);
}
if (status) {
conditions.push("d.status = ?");
args.push(status);
}
if (conditions.length > 0) {
sql += " WHERE " + conditions.join(" AND ");
}
sql += ` ORDER BY d.created_at DESC LIMIT ? OFFSET ?`;
args.push(n, off);
return db.query(sql, args);
}
Key points:
?) are used for every user-provided value. Never concatenate user input into SQL strings.conditions array collects WHERE clauses. The args array collects their values, in the same order.LIMIT and OFFSET correctly because SQLite accepts them as bound parameters.fs moduleThe fs module lets scripts write files to disk. This is useful for exporting query results, generating manifests, or writing intermediate artifacts.
const fs = require("fs");
function exportSources(database, outDir) {
openDatabase(database);
const dir = outDir || "exports";
fs.mkdirSync(dir, { recursive: true });
const rows = db.query("SELECT * FROM sources");
for (const source of rows) {
const fileName = `${dir}/${source.id}.json`;
fs.writeFileSync(fileName, JSON.stringify(source, null, 2), "utf-8");
}
return { exported: rows.length, directory: dir };
}
Important details:
fs.mkdirSync(dir, { recursive: true }) creates nested directories. The recursive flag is parsed by the Go backend.fs.writeFileSync accepts a string, Buffer, Uint8Array, or DataView. When writing text, pass "utf-8" as the encoding.writeFile, mkdir, etc.) return Promises, but in verb mode the function is evaluated synchronously. Using the Sync variants is usually simpler.The goja-text package provides a markdown module backed by goldmark. It parses Markdown into an AST, walks the tree, renders to HTML, and extracts text content.
const markdown = require("markdown");
function headingCount(text) {
const ast = markdown.parse(text);
let count = 0;
markdown.walk(ast, (node) => {
if (node.Type === "heading") {
count++;
}
});
return count;
}
Nodes are Go objects with PascalCase fields: Type, Children, Level, Destination, Text, etc. The walk visitor receives (node, context). Returning false or "skip" skips children. Returning "stop" halts traversal entirely.
A complete analysis pattern:
function markdownStats(database, docId) {
openDatabase(database);
const rows = db.query(
"SELECT content_text, raw_content FROM documents WHERE id = ?", docId
);
if (rows.length === 0) throw new Error("document not found");
const text = rows[0].content_text || rows[0].raw_content || "";
const ast = markdown.parse(text);
let headings = 0;
let paragraphs = 0;
markdown.walk(ast, (node) => {
if (node.Type === "heading") headings++;
if (node.Type === "paragraph") paragraphs++;
});
return { docId, headings, paragraphs, chars: text.length };
}
Two goja-text modules help when scripts consume unstructured or partially structured text: sanitize and extract.
sanitizesanitize repairs malformed YAML and JSON. This is useful when scripts read configuration files or LLM outputs that may contain syntax errors.
const sanitize = require("sanitize");
const result = sanitize.yaml.sanitize("name:Alice\n age: 30\n");
console.log(result.Sanitized); // repaired YAML string
console.log(result.Fixes); // array of fixes applied
console.log(result.Issues); // array of remaining issues
The result object has PascalCase fields: Sanitized, Fixes, Issues, ParseClean, StrictParseClean. Always defensively access Fixes and Issues because they may be null if the input is clean:
const fixes = (result.Fixes || []).map((f) => f.Rule);
extractextract finds structured data candidates inside larger text blocks. It searches for Markdown fenced code blocks, XML-like tags (<json>...</json>), YAML frontmatter, and raw JSON/YAML strings.
const extract = require("extract");
const text = `Some notes\n~~~json\n{"ok": true}\n~~~\n<yaml>name: test</yaml>`;
const candidates = extract.all(text);
Each candidate exposes Kind, Format, Text, Confidence, and source position fields. The extract.validate(candidate) function checks whether a candidate can be parsed as strict JSON or YAML and returns a validation result.
expressThe express module provides a minimal HTTP routing API. It is not a full Express.js implementation, but it covers the essentials: route registration, path parameters, static file serving, and JSON responses.
const express = require("express");
function serveApi(database, port) {
openDatabase(database);
const app = express.app();
app.get("/api/sources", (_req, res) => {
const rows = db.query("SELECT id, name FROM sources");
res.json({ sources: rows });
});
app.get("/api/documents/:id", (req, res) => {
const rows = db.query("SELECT * FROM documents WHERE id = ?", req.params.id);
res.json(rows[0] || { error: "not found" });
});
return {
ok: true,
port: port || 8787,
note: "Server is running. Use --keep-alive for long-lived mode."
};
}
Important notes:
req.params.id.res.json(obj) serializes the object to JSON and sets the content type.rag-eval-js verbs run with --http-enabled true and --http-listen 127.0.0.1:8787.rag-eval-js run script.js --keep-alive --http-listen 127.0.0.1:8789.cd cmd/rag-eval
xgoja build -f xgoja.yaml --output dist/rag-eval-js --xgoja-replace ../../../go-go-goja
The --xgoja-replace flag is needed because the workspace uses local checkouts via Go workspace mode.
./dist/rag-eval-js explorer --help
./dist/rag-eval-js explorer sources --database ../../data/rag-eval.db
./dist/rag-eval-js explorer sources --database ../../data/rag-eval.db --output json
Glazed supports table, csv, tsv, json, yaml, sql, template, and markdown.
./dist/rag-eval-js eval 'const db = require("db"); db.configure("sqlite3", "data/rag-eval.db"); console.log(db.query("SELECT COUNT(*) AS n FROM documents")[0].n);'
./dist/rag-eval-js repl
Most verbs should accept a --database flag with a sensible default:
const DEFAULT_DB = "data/rag-eval.db";
function openDatabase(database) {
const path = database || DEFAULT_DB;
db.configure("sqlite3", path);
return path;
}
This matches the Go CLI convention and lets users override the path when working with alternate databases.
A verb can return any JSON-serializable structure. The docDetail verb returns a nested object:
return {
document: doc,
chunks: chunkRows,
strategies: strategies,
embeddings: embeddings,
artifacts: artifacts
};
When formatted as JSON, this produces a single nested object. When formatted as a table, Glazed flattens the top-level keys into columns.
yaml for config readingconst yaml = require("yaml");
const fs = require("fs");
function configProbe(file) {
const text = fs.readFileSync(file, "utf-8");
const parsed = yaml.parse(text);
return { file, topLevelKeys: Object.keys(parsed || {}) };
}
| Problem | Cause | Solution |
|---|---|---|
database not configured | Forgot to call db.configure() | Add db.configure("sqlite3", path) before first query |
Cannot read property 'map' of undefined | Accessing .Fixes or .Issues on a clean sanitize result | Use (result.Fixes | | []).map(...) |
address already in use | Default HTTP port 8787 is occupied | Pass --http-listen 127.0.0.1:8788 or use run --keep-alive |
TypeError: Cannot read property 'n' of undefined | Query returned no rows | Check rows.length before indexing into rows[0] |
xgoja build fails with module errors | Local replace paths are wrong | Ensure --xgoja-replace points to the correct go-go-goja checkout |
| Verb not appearing in help | File not embedded or binary not rebuilt | Rebuild with xgoja build after adding .js files |
console.log output is mixed with JSON | Using console.log inside a verb that returns data | Return the data; the framework handles output. Use console.log only for debugging |
rag-eval-js is a custom binary generated by xgoja from xgoja.yaml. Rebuild it after every script change.__package__() and registers functions with __verb__().db module gives full SQLite access via configure, query, and exec. Always use placeholders (?) for user input.fs module supports both sync and async file operations. mkdirSync accepts { recursive: true }.markdown, sanitize, and extract modules from goja-text provide structured text processing backed by Go libraries.express module registers HTTP routes. For long-lived servers, use run --keep-alive.glaze help how-to-write-good-documentation-pages — Glazed documentation style guiderag-eval-js help explorer — Reference for the built-in explorer verbsrag-eval-js help database — Reference for the built-in database verbsrag-eval-js help capabilities — Module availability smoke testscmd/rag-eval/xgoja.yaml — The build specification that defines the runtimecmd/rag-eval/jsverbs/ — The directory where all verb packages live