---
title: How to write rag-eval-js scripts
description: 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.
doc_version: 1
last_updated: 2026-07-17
---


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.

```text
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:

```js
__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:

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

### 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`

```js
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:

```js
const DEFAULT_DB = "data/rag-eval.db";

function openDatabase(database) {
  const path = database || DEFAULT_DB;
  db.configure("sqlite3", path);
  return path;
}
```

### 4.2 `query`

```js
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`

```js
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:

```js
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.

```js
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.

```js
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:

```js
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.

```js
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:

```js
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.

```js
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.

```js
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

```bash
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

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

### 10.3 Run a verb

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

### 10.4 Change output format

```bash
./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

```bash
./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

```bash
./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:

```js
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:

```js
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

```js
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

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

## 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
