Lang on Rock
Getting started

Quickstart

Import a folder of bundles, read it back, commit a change, and restore the revision before it.

Lay out a folder of bundles

Start from a folder where every immediate subdirectory is one bundle.

sources/acme/
  sales/
    tables/orders.md
    tables/customers.md
  ops/
    runbooks/deploy.md

sales and ops are bundles. orders, customers and deploy will be concepts. Each file is ordinary Markdown with YAML frontmatter:

sources/acme/sales/tables/orders.md
---
type: BigQuery Table
title: Orders
description: One row per completed customer order.
grain: order_id
---

# Schema

| Column        | Type      | Notes                             |
| ------------- | --------- | --------------------------------- |
| `order_id`    | STRING    | Primary key.                      |
| `customer_id` | STRING    | Joins to [customers](./customers.md). |
| `total`       | NUMERIC   | Order total in the store currency. |
| `placed_at`   | TIMESTAMP | When checkout completed, UTC.     |

# Joins

Join to [customers](./customers.md) on `customer_id`.

Frontmatter is optional: a plain Markdown file still compiles, with its id from the path, its summary from the first sentence and its title from the first heading. It earns a conformance warning, which --strict turns into a failing exit. Only navigation files, index.md and log.md, never become concepts.

Import it

langonrock import sources/acme --data ./data --tenant acme
snapshot cd9078b9af0b (new), 2 bundles [ops sales], 3 concepts, 1236 bytes on disk

That is the first commit of the tenant acme. Import reads every subdirectory as a bundle, compiles the documents into a snapshot and stores the exact Markdown beside it. The folder is interchange, not storage: nothing ever writes back to it, and import remembers what it read, so running it again on an unchanged folder changes nothing and says (reused).

Read the manifest

langonrock manifest --data ./data --tenant acme
# tenant: acme
# bundles: ops sales
id	bundle	kind	status	grain	summary	links
deploy	ops	runbook	-	-	How to ship the orders service.	-
customers	sales	bigquery_table	deprecated	customer_id	Registered customers, including churned.	-
orders	sales	bigquery_table	-	order_id	One row per completed customer order.	customers

Everything an agent needs to plan its reads is in those rows. The links column tells it which concepts belong together before it fetches anything. A status cell other than - is the concept saying it is not current: deprecated, draft, or stale once its stale_after date passes, which the reader computes at read time so the stored bytes never depend on the clock.

Fetch only what you chose

langonrock get orders --section schema --data ./data --tenant acme
@@ orders
# Schema

| Column        | Type      | Notes                             |
| ------------- | --------- | --------------------------------- |
| `order_id`    | STRING    | Primary key.                      |
| `customer_id` | STRING    | Joins to [customers](./customers.md). |
| `total`       | NUMERIC   | Order total in the store currency. |
| `placed_at`   | TIMESTAMP | When checkout completed, UTC.     |

Section names come from the concept's own Markdown headings, lowercased with non-alphanumerics collapsed to underscores, so a # Common query patterns heading becomes --section common_query_patterns. Text before the first heading is the body section.

Drop --section for the whole concept, and pass several ids to fetch them in one call. When you know a phrase rather than a section, --find "exact words" returns the window around it plus the offset of every occurrence.

Commit a change

A transaction is a JSON batch of document writes and deletes. This one adds a metric:

batch.json
{
  "changes": [
    {
      "operation": "write",
      "bundle": "sales",
      "path": "metrics/revenue.md",
      "content": "---\ntype: Metric\ntitle: Revenue\ndescription: Sum of order totals per day.\n---\n\n# Definition\n\n`SUM(total)` over [orders](../tables/orders.md), grouped by `DATE(placed_at)`.\n"
    }
  ]
}
langonrock transact --data ./data --tenant acme --from batch.json | jq
{
  "revision": "0d07a9f035a9416201367d342e146b379021a43cc9234b699c7c559e4ee7b808",
  "snapshot": "0203add32834701b39b60d1b330ad88349a42c93e52bf29d0a660961372098ff",
  "concepts": 4,
  "bundles": [
    "ops",
    "sales"
  ],
  "diagnostics": []
}

The change carries no replaces, which asserts that the document is new. Run the same batch again and it is refused, with nothing published:

concept already exists: re-read the concept and retry with its new hash

To change or delete an existing document, a change names the hash of the version it replaces. The next manifest already has the new row:

revenue	sales	metric	-	-	Sum of order totals per day.	orders

Take it back

Every commit is a revision. History lists them newest first:

langonrock history --data ./data --tenant acme | jq
{
  "revisions": [
    {
      "revision": "0d07a9f035a9416201367d342e146b379021a43cc9234b699c7c559e4ee7b808",
      "parent": "e9c762984ea34398083fa766b787af4d307673612eead03416a3a80dec46112f",
      "snapshot": "0203add32834701b39b60d1b330ad88349a42c93e52bf29d0a660961372098ff",
      "created": "2026-10-01T02:42:30.592Z",
      "concepts": 4,
      "bundles": [
        "ops",
        "sales"
      ]
    },
    {
      "revision": "e9c762984ea34398083fa766b787af4d307673612eead03416a3a80dec46112f",
      "parent": null,
      "snapshot": "cd9078b9af0b1d912a9e6e0c8c12d409be3aac3394085d56dc618458b5e23ad8",
      "created": "2026-10-01T02:42:30.513Z",
      "concepts": 3,
      "bundles": [
        "ops",
        "sales"
      ]
    }
  ]
}

Restore the import, naming the revision you expect to be current:

FIRST=$(langonrock history --data ./data --tenant acme | jq -r '.revisions[-1].revision')
CURRENT=$(langonrock history --data ./data --tenant acme | jq -r '.revisions[0].revision')
langonrock restore "$FIRST" --expected-revision "$CURRENT" --data ./data --tenant acme | jq
{
  "revision": "43e751dc1a93148c61e8c35ff3134b942442513340ab9b1f841cc1d4c5bc743e",
  "snapshot": "cd9078b9af0b1d912a9e6e0c8c12d409be3aac3394085d56dc618458b5e23ad8",
  "concepts": 3,
  "bundles": [
    "ops",
    "sales"
  ],
  "diagnostics": []
}

Restore publishes the old source as a new commit, so the history keeps all three revisions. The snapshot is the one the import produced, byte for byte, because identical documents compile to identical bytes; the revision is new, because a revision identifies a commit. Had anyone committed in between, the restore would have been refused with database changed: re-read the revision and document hashes.

Keep it following a folder

Importing by hand gets old. Run the watcher instead:

langonrock watch sources/acme --data ./data --tenant acme

Add a bundle by creating a folder, change a document by saving its file, remove one by deleting it. The watcher debounces filesystem events at 200 ms, ignores dot directories such as .git, and rescans the whole folder every 30 seconds as a backstop, because watch events are a hint on every platform. A document edited in the database since the last import is never overwritten by the folder: if both sides changed, the import fails as a whole and the watcher reports the conflict. Documents created in the database stay where they are.

If you are going to run the daemon anyway, skip this command. serve watches the folders listed in sources.json itself, and running both would put two watchers on one tenant. More on imports, conflicts and exact export in Import, watch and export.

Hand it to an agent

langonrock mcp "okf://$PWD/data?tenant=acme"

For Claude Code, register it once:

claude mcp add langonrock -- langonrock mcp "okf:///abs/path/to/data?tenant=acme"

Six tools by default: manifest, search, get and snapshot to read, write and delete to commit one document at a time. --database-tools adds transact, history and restore. See MCP server for what each one is for and why the count is capped.

Searching instead of reading everything

langonrock query "okf:///abs/path/to/data?tenant=acme" search order total
# tenant: acme
# bundles: ops sales
# query: order total
# hits: 1 direct, 1 linked
id	bundle	kind	status	grain	summary	links	pos
orders	sales	bigquery_table	-	order_id	One row per completed customer order.	customers	0
customers	sales	bigquery_table	deprecated	customer_id	Registered customers, including churned.	-	-

Search returns manifest rows, never bodies. Narrow with search, then fetch the ids you chose with get. Results include concepts one link away from the top matches, which is usually where the join partner or the parent dataset lives; that is the linked count. Each direct hit also ends in a pos column, the offset where your query's words cluster densest in the body, so the second hop can be get with --offset <pos> --limit 2000 instead of the whole document. Linked rows, and hits whose words appear only in the manifest row, show -.

Progress goes to stderr

Stdout stays clean for piping, and Bun paints anything written with console.error red. Those lines are status, not failures.

On this page