Scripting API

Automate and extend your knowledge base with the Thalo scripting API

Scripting API

The Thalo scripting API provides programmatic access to your knowledge base, enabling you to write scripts that iterate over entries, find connections, run queries, and create custom validation rules.

Installation

The scripting API is part of the main @rejot-dev/thalo package:

pnpm add @rejot-dev/thalo

Quick Start

import { loadThalo } from "@rejot-dev/thalo/api";

// Load your knowledge base
const workspace = await loadThalo("./my-knowledge-base");

// Iterate over all entries
for (const entry of workspace.entries()) {
  console.log(`${entry.timestamp} - ${entry.title}`);
}

// Query for specific entries
const codingOpinions = workspace.query("opinion where #coding");

// Find all references to a link (^my-important-note)
const refs = workspace.findReferences("my-important-note");

Loading a Workspace

From a Directory

The most common way to load a workspace is from a directory containing your .thalo and .md files:

import { loadThalo } from "@rejot-dev/thalo/api";

// Load all .thalo and .md files from a directory
const workspace = await loadThalo("./my-knowledge-base");

// Only load .thalo files
const workspace = await loadThalo("./kb", { extensions: [".thalo"] });

From Specific Files

You can also load specific files:

import { loadThaloFiles } from "@rejot-dev/thalo/api";

const workspace = await loadThaloFiles(["./entries.thalo", "./syntheses.thalo"]);

Iterating Over Entries

All Entries

// Get all entries across all files
const allEntries = workspace.entries();

// Get entries from a specific file
const fileEntries = workspace.entriesInFile("./entries.thalo");

By Entry Type

// Instance entries (create/update)
const instances = workspace.instanceEntries();
for (const entry of instances) {
  console.log(`${entry.entity}: ${entry.title}`);
  console.log(`  Directive: ${entry.directive}`); // "create" or "update"
}

// Schema entries (define-entity/alter-entity)
const schemas = workspace.schemaEntries();
for (const entry of schemas) {
  console.log(`Entity: ${entry.entityName}`);
  console.log(`  Directive: ${entry.directive}`); // "define-entity" or "alter-entity"
}

// Synthesis entries (define-synthesis)
const syntheses = workspace.synthesisEntries();

// Actualize entries (actualize-synthesis)
const actualizations = workspace.actualizeEntries();

Entry Properties

Every ThaloEntry has these common properties:

interface ThaloEntry {
  type: "instance" | "schema" | "synthesis" | "actualize";
  file: string; // File path
  timestamp: string; // ISO format (e.g., "2026-01-08T14:30Z")
  title: string; // Entry title
  linkId?: string; // Link ID without ^ prefix
  tags: string[]; // Tags without # prefix
  location: Location; // Source location
  raw: Entry; // Raw AST for advanced use
}

Navigate your knowledge base using links (^link-id) and tags (#tag-name). Both methods require the prefix to be explicit.

Find Definition

Jump to where a link ID is defined. Only works with links (^), not tags.

// Find where ^my-note is defined
const def = workspace.findDefinition("^my-note");

if (def) {
  console.log(`Defined in ${def.file}`);
  console.log(`Line ${def.location.startPosition.row + 1}`);
  console.log(`Entry: ${def.entry.title}`);
}

// Error: tags don't have definitions
// workspace.findDefinition("#coding"); // throws Error

Find References

Find all places that reference a link (^link-id) or all entries with a tag (#tag-name).

// Find all occurrences of ^my-note (including where it's defined)
const linkRefs = workspace.findReferences("^my-note");

// Only references, not the definition
const refsOnly = workspace.findReferences("^my-note", false);

for (const ref of linkRefs) {
  if (ref.kind === "link") {
    const label = ref.isDefinition ? "defined" : "referenced";
    console.log(`${label} in ${ref.file}:${ref.location.startPosition.row + 1}`);
  }
}

Tag References

// Find all entries with a tag
const tagRefs = workspace.findReferences("#coding");

for (const ref of tagRefs) {
  if (ref.kind === "tag") {
    console.log(`${ref.entry.title} in ${ref.file}`);
  }
}

The prefix is required. Calling findReferences("my-note") without ^ or # will throw an error.

Querying

Use the Thalo query syntax to find entries:

// All opinions
const opinions = workspace.query("opinion");

// Opinions with a specific tag
const codingOpinions = workspace.query("opinion where #coding");

// With field conditions
const highConfidence = workspace.query('opinion where confidence = "high"');

// Multiple conditions (AND)
const filtered = workspace.query('opinion where #coding and confidence = "high"');

// Multiple entity types (OR)
const mixed = workspace.query("opinion, lore");

Query errors throw exceptions:

try {
  const results = workspace.query("nonexistent-entity");
} catch (error) {
  console.error(error.message);
  // "Unknown entity type: 'nonexistent-entity'. Define it using 'define-entity'."
}

Filtering by Checkpoint

Create a filtered workspace view that only includes entries after a checkpoint:

import { loadThalo } from "@rejot-dev/thalo/api";
import { createChangeTracker } from "@rejot-dev/thalo/change-tracker/node";

const workspace = await loadThalo(".");

// Timestamp-based filtering
const sinceTimestamp = await workspace.filteredSince("ts:2026-01-10T15:00Z");

// Git-based filtering (requires tracker)
const tracker = await createChangeTracker();
const sinceGit = await workspace.filteredSince("git:abc123def456", { tracker });

for (const entry of sinceGit.entries()) {
  console.log(`${entry.timestamp} - ${entry.title}`);
}

Git-based filtering uses the change tracker and applies to instance entries (create/update) and schema entries (define/alter-entity). Other entry types are left unfiltered. Use timestamp checkpoints if you need all entry types filtered by time.

Watching for Changes

Use workspace.watch() to listen for new, updated, or removed entries. It returns an async iterator that yields change events as files change.

import { loadThalo } from "@rejot-dev/thalo/api";

const workspace = await loadThalo(".");
const controller = new AbortController();

// Emit an initial event with all current entries
const changes = workspace.watch({
  includeExisting: true,
  signal: controller.signal,
});

for await (const change of changes) {
  for (const entry of change.added) {
    console.log(`Added: ${entry.title}`);
  }
  for (const entry of change.updated) {
    console.log(`Updated: ${entry.title}`);
  }
  for (const entry of change.removed) {
    console.log(`Removed: ${entry.title}`);
  }
}

// Later: stop watching
controller.abort();
Watching is available in Node.js environments only (uses file system watching).

Validation

Run the checker to find issues:

const diagnostics = workspace.check();

for (const d of diagnostics) {
  console.log(`${d.severity}: ${d.message}`);
  console.log(`  ${d.file}:${d.line}:${d.column}`);
  console.log(`  Rule: ${d.code}`);
}

Configure which rules run:

const diagnostics = workspace.check({
  rules: {
    "unknown-entity": "error",
    "missing-title": "warning",
    "empty-section": "off",
  },
});

Custom Visitors

The visitor pattern lets you process entries with custom logic:

// Count entries by entity type
const counts = new Map<string, number>();

workspace.visit({
  visitInstanceEntry(entry) {
    const count = counts.get(entry.entity) ?? 0;
    counts.set(entry.entity, count + 1);
  },
});

console.log(counts);
// Map { "opinion" => 15, "lore" => 42, "journal" => 8 }

Visitor Interface

interface EntryVisitor {
  visitInstanceEntry?(entry: ThaloInstanceEntry, context: VisitorContext): void;
  visitSchemaEntry?(entry: ThaloSchemaEntry, context: VisitorContext): void;
  visitSynthesisEntry?(entry: ThalaSynthesisEntry, context: VisitorContext): void;
  visitActualizeEntry?(entry: ThaloActualizeEntry, context: VisitorContext): void;
}

The context provides access to the workspace and current file:

workspace.visit({
  visitInstanceEntry(entry, context) {
    console.log(`Processing entry in ${context.file}`);

    // Access the workspace for cross-referencing
    if (entry.linkId) {
      const refs = context.workspace.findReferences(`^${entry.linkId}`, false);
      console.log(`Found ${refs.length} incoming reference(s).`);
    }
  },
});

Example Scripts

Export to JSON

import { loadThalo } from "@rejot-dev/thalo/api";
import { writeFileSync } from "fs";

async function exportToJson() {
  const workspace = await loadThalo(".");

  const data = workspace.instanceEntries().map((entry) => ({
    type: entry.entity,
    title: entry.title,
    timestamp: entry.timestamp,
    tags: entry.tags,
    linkId: entry.linkId,
  }));

  writeFileSync("export.json", JSON.stringify(data, null, 2));
}

exportToJson();
import { loadThalo } from "@rejot-dev/thalo/api";

async function findOrphanedLinks() {
  const workspace = await loadThalo(".");
  const orphans: string[] = [];

  workspace.visit({
    visitInstanceEntry(entry) {
      if (entry.linkId) {
        const refs = workspace.findReferences(`^${entry.linkId}`, false);
        if (refs.length === 0) {
          orphans.push(`^${entry.linkId} (${entry.title})`);
        }
      }
    },
  });

  if (orphans.length > 0) {
    console.log("Entries with no incoming references:");
    orphans.forEach((o) => console.log(`  ${o}`));
  } else {
    console.log("All entries are referenced!");
  }
}

findOrphanedLinks();

Tag Statistics

import { loadThalo } from "@rejot-dev/thalo/api";

async function tagStats() {
  const workspace = await loadThalo(".");
  const tagCounts = new Map<string, number>();

  workspace.visit({
    visitInstanceEntry(entry) {
      for (const tag of entry.tags) {
        tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
      }
    },
  });

  // Sort by count
  const sorted = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);

  console.log("Tag usage:");
  for (const [tag, count] of sorted) {
    console.log(`  #${tag}: ${count}`);
  }
}

tagStats();

Custom Validation Rule

import { loadThalo } from "@rejot-dev/thalo/api";

async function customValidation() {
  const workspace = await loadThalo(".");
  const issues: string[] = [];

  workspace.visit({
    visitInstanceEntry(entry) {
      // Rule: Opinions must have exactly one tag
      if (entry.entity === "opinion" && entry.tags.length !== 1) {
        issues.push(
          `${entry.file}:${entry.location.startPosition.row + 1}: ` +
            `Opinion "${entry.title}" should have exactly one tag, ` +
            `found ${entry.tags.length}`,
        );
      }

      // Rule: All entries should have a link ID
      if (!entry.linkId) {
        issues.push(
          `${entry.file}:${entry.location.startPosition.row + 1}: ` +
            `Entry "${entry.title}" is missing a link ID`,
        );
      }
    },
  });

  if (issues.length > 0) {
    console.log("Custom validation issues:");
    issues.forEach((i) => console.log(`  ${i}`));
    process.exit(1);
  } else {
    console.log("All custom validations passed!");
  }
}

customValidation();

Accessing Raw AST

For advanced use cases, access the raw AST through the raw property:

workspace.visit({
  visitInstanceEntry(entry) {
    // Access metadata fields
    for (const meta of entry.raw.metadata) {
      console.log(`${meta.key.value}: ${meta.value.raw}`);
    }

    // Access content sections
    if (entry.raw.content) {
      for (const child of entry.raw.content.children) {
        if (child.type === "markdown_header") {
          console.log(`Section: ${child.text}`);
        }
      }
    }
  },
});

TypeScript Support

The API is fully typed. Import types as needed:

import type {
  ThaloWorkspaceInterface,
  ThaloEntry,
  ThaloInstanceEntry,
  ThaloSchemaEntry,
  EntryVisitor,
  VisitorContext,
  DiagnosticInfo,
} from "@rejot-dev/thalo/api";