How to write rag-eval-js scripts

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.

Sections

Terminology & Glossary
📖 Documentation
Navigation
8 sectionsv0.1
📄 How to write rag-eval-js scripts — glaze help how-to-write-rag-eval-js-scripts
how-to-write-rag-eval-js-scripts

How to write rag-eval-js scripts

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.

Tutorialrag-evalxgojascriptingjavascriptjsverbssqliterag-eval-jsrag-eval-js evalrag-eval-js runrag-eval-js repldatabasekeep-alivehttp-listenoutput

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.

1. Why a scripting layer exists

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.

2. The mental model

Before writing any code, understand the three layers:

2.1 The build layer

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

2.2 The runtime layer

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.

2.3 The verb layer

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.

3. Anatomy of a jsverb file

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" }
  }
});

3.1 __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.

3.2 require

require("db") loads the native database module. The available modules are determined by xgoja.yaml. For rag-eval-js, the standard set is:

ModuleWhat it provides
dbSQLite query and exec
fsFile read, write, mkdir, stat
expressHTTP route registration
markdownParse, render, walk, validate Markdown
sanitizeYAML and JSON repair (
extractStructured-data candidate extraction
yamlYAML parse and stringify
pathPath manipulation

3.3 The verb function

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.

3.4 __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.

4. Opening the database

The db module is the most important module for corpus scripts. It exposes three functions: configure, query, and exec.

4.1 configure

db.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;
}

4.2 query

const 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.

4.3 exec

const 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.

5. Writing a verb with dynamic filters

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:

  • Placeholders (?) are used for every user-provided value. Never concatenate user input into SQL strings.
  • The conditions array collects WHERE clauses. The args array collects their values, in the same order.
  • The framework handles LIMIT and OFFSET correctly because SQLite accepts them as bound parameters.

6. Exporting data with the fs module

The 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.
  • Async versions (writeFile, mkdir, etc.) return Promises, but in verb mode the function is evaluated synchronously. Using the Sync variants is usually simpler.

7. Parsing and analyzing Markdown

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 };
}

8. Structured data extraction and repair

Two goja-text modules help when scripts consume unstructured or partially structured text: sanitize and extract.

8.1 sanitize

sanitize 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);

8.2 extract

extract 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.

9. Starting an HTTP server with express

The 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:

  • Route parameters are available as req.params.id.
  • res.json(obj) serializes the object to JSON and sets the content type.
  • The server only starts listening if the runtime is launched with HTTP enabled. By default, rag-eval-js verbs run with --http-enabled true and --http-listen 127.0.0.1:8787.
  • For a long-lived server, use rag-eval-js run script.js --keep-alive --http-listen 127.0.0.1:8789.

10. Building and testing

10.1 Rebuild after every script change

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.

10.2 List available verbs

./dist/rag-eval-js explorer --help

10.3 Run a verb

./dist/rag-eval-js explorer sources --database ../../data/rag-eval.db

10.4 Change output format

./dist/rag-eval-js explorer sources --database ../../data/rag-eval.db --output json

Glazed supports table, csv, tsv, json, yaml, sql, template, and markdown.

10.5 Evaluate a quick expression

./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);'

10.6 Run an interactive REPL

./dist/rag-eval-js repl

11. Common patterns

11.1 Choosing the default database path

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.

11.2 Returning nested results

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.

11.3 Using yaml for config reading

const 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 || {}) };
}

12. Troubleshooting

ProblemCauseSolution
database not configuredForgot to call db.configure()Add db.configure("sqlite3", path) before first query
Cannot read property 'map' of undefinedAccessing .Fixes or .Issues on a clean sanitize resultUse (result.Fixes | | []).map(...)
address already in useDefault HTTP port 8787 is occupiedPass --http-listen 127.0.0.1:8788 or use run --keep-alive
TypeError: Cannot read property 'n' of undefinedQuery returned no rowsCheck rows.length before indexing into rows[0]
xgoja build fails with module errorsLocal replace paths are wrongEnsure --xgoja-replace points to the correct go-go-goja checkout
Verb not appearing in helpFile not embedded or binary not rebuiltRebuild with xgoja build after adding .js files
console.log output is mixed with JSONUsing console.log inside a verb that returns dataReturn the data; the framework handles output. Use console.log only for debugging

13. Key points

  • rag-eval-js is a custom binary generated by xgoja from xgoja.yaml. Rebuild it after every script change.
  • A jsverb file declares a package with __package__() and registers functions with __verb__().
  • The db module gives full SQLite access via configure, query, and exec. Always use placeholders (?) for user input.
  • The fs module supports both sync and async file operations. mkdirSync accepts { recursive: true }.
  • The markdown, sanitize, and extract modules from goja-text provide structured text processing backed by Go libraries.
  • The express module registers HTTP routes. For long-lived servers, use run --keep-alive.
  • Glazed handles CLI parsing, flag validation, and output formatting. The verb author focuses only on the query logic.

See Also

  • glaze help how-to-write-good-documentation-pages — Glazed documentation style guide
  • rag-eval-js help explorer — Reference for the built-in explorer verbs
  • rag-eval-js help database — Reference for the built-in database verbs
  • rag-eval-js help capabilities — Module availability smoke tests
  • cmd/rag-eval/xgoja.yaml — The build specification that defines the runtime
  • cmd/rag-eval/jsverbs/ — The directory where all verb packages live