Lang on Rock
Guides

Editing over the network

Create, change and delete concepts through the API, without losing anyone's edit.

An editor needs to create, change and delete concepts remotely. It does that by writing the source Markdown, never a snapshot. The watcher recompiles from source, so it would overwrite a snapshot written directly within seconds.

There is a working implementation of everything on this page

langoneditor is a desktop editor, visualizer and import/export tool for OKF bundles, built on exactly these routes. It is the reference for how this API is meant to be consumed, including what to do when a precondition fails.

Turning it on

Two files, both covered in Running a server.

sources.json says where a tenant's Markdown is. tokens.json says which tokens may change it. A tenant with no entry in sources.json stays readable, and every method except PUT refuses with a 409, which is the right default and needs no flag.

A PUT is the exception: a tenant with no directory and no snapshots is created by it, registered, and watched from then on, so an agent asked to persist a first note needs no setup. Only the tenant the token already grants is ever created. A tenant that has snapshots but no entry — what langonrock sync on the command line leaves behind — is still refused, because compiling a fresh empty folder over it would replace its manifest with nothing.

{ "acme": "/home/me/okf/sources/acme" }
{ "editor-token": { "tenant": "acme", "write": true } }

The routes

GET    /v1/{tenant}/source                 list files with sizes and hashes
GET    /v1/{tenant}/source/{bundle}/{path} read one, ETag is its content hash
PUT    /v1/{tenant}/source/{bundle}/{path} write
DELETE /v1/{tenant}/source/{bundle}/{path} delete
DELETE /v1/{tenant}/bundles/{bundle}       delete a whole bundle
POST   /v1/{tenant}/sync                   recompile now, return the new digest

Writing the first file into a folder creates the bundle, and deleting the folder removes it, exactly as it works on disk.

Every write names the version it replaces

There is no default

If-Match: "<hash>" to overwrite, or If-None-Match: * to insist the concept is new. Neither header is a 428. A stale hash is a 412.

Two people editing one concept is the normal case for an editor, and a silently lost update is the worst failure this API could have. Requiring the header makes the unsafe call impossible rather than merely discouraged.

PreconditionConcept existsResult
If-None-Match: *no204, created
If-None-Match: *yes412, concept already exists
If-Match: "<hash>"yes, same hash204, replaced
If-Match: "<hash>"yes, different412, concept changed since it was read
If-Match: "<hash>"no412, concept does not exist
noneeither428

A successful write answers 204 with the new hash in the ETag, so the client can chain edits without re-reading.

What to do with a 412

Refusing the write is the server's whole job here. Deciding what happens next belongs to the client, and there are only two honest options: discard one side, or merge.

langoneditor shows a three-way merge rather than overwriting, and rebases all three resolutions onto the version it just observed, so a retry cannot fail for the same reason twice. It also polls the snapshot digest every few seconds and warns when the store moves underneath it, which catches the case where someone is editing the same bundle in Obsidian alongside.

Deleting a whole bundle carries no precondition. The previous snapshot still holds every concept until the collector runs, so a mistake is recoverable from the store's own history.

Through the library

The precondition is an argument, enforced identically whether you are embedded or remote.

const knowledge = open('okf+https://host:7777?token=editor-token');

const before = await knowledge.readSource('sales', 'tables/orders.md');
await knowledge.writeSource('sales', 'tables/orders.md', edited, before?.hash);
await knowledge.writeSource('sales', 'metrics/new.md', created); // no hash: must not exist
const { snapshot } = await knowledge.sync();

Embedded enforces the same rule on purpose. If only the server checked, an editor developed against a local path would lose edits the moment it was pointed at a server, which is exactly the bug this is meant to prevent.

The listing tells you which concept a file becomes

{
  "bundle": "sales",
  "path": "tables/orders.md",
  "id": "sales/orders",
  "hash": "…",
  "bytes": 412
}

A client never has to reimplement the naming rule. A file with no id is not a concept, which now means only the navigation files, index.md and log.md: everything else compiles, frontmatter or not, and the listing names it exactly as the manifest will.

Creating a file can rename a concept nobody touched

Concept ids are the shortest unambiguous form of their path, so adding staging/orders.md turns an existing orders into tables/orders. Re-read the listing or the manifest after a sync rather than assuming ids are stable.

Diagnostics are the lint

sync returns what the compiler noticed on the way. A missing type, a link resolving to nothing, a file compiled without frontmatter. That is what an editor should put in front of whoever is writing.

const { snapshot, concepts, bundles, diagnostics } = await knowledge.sync();

Each diagnostic carries a level, the file path, and a message.

MessageWhat it means
no frontmatter, compiled as plain markdown, not an OKF conceptThe file compiled with derived metadata
missing required frontmatter field "type"The kind cell will be -
unresolved link "…"A Markdown link points at no concept in the tenant
stale_after is not a date (YYYY-MM-DD), ignoredThe concept will never demote to stale
invalid YAML frontmatter: …The block did not parse, so every field was lost
frontmatter is not a mappingIt parsed to a list or a scalar

Every one of these is warn. The error level exists in the type and nothing emits it today, so a compile always produces a snapshot. Use --strict on the CLI to make any diagnostic an exit code instead.

When the snapshot appears

PUT returns as soon as the file is on disk, so saving is fast. The snapshot follows on the watcher's debounce, 200 ms by default.

Call sync when you need the new digest immediately. It waits for a compile that began after the call, never one already in flight that may have read the tree before your write landed. A caller that asks for the new snapshot and gets the previous digest is the same failure as a write that silently vanishes.

On a large tenant a compile is a few hundred milliseconds, so an aggressively autosaving editor should raise --debounce rather than sync on every keystroke.

From the command line

The same verbs work against any connection string, so you can edit a local store and a remote one the same way.

langonrock query "$DSN" source                              # list, with hashes
langonrock query "$DSN" read sales tables/orders.md         # content out, hash on stderr
langonrock query "$DSN" write sales metrics/new.md --create < new.md
langonrock query "$DSN" write sales tables/orders.md --replaces "$HASH" < edited.md
langonrock query "$DSN" delete sales tables/old.md --force
langonrock query "$DSN" delete-bundle old-bundle
langonrock query "$DSN" sync

--create, --replaces <hash> and --force are the command-line spelling of the precondition. The CLI does not quietly read the current hash for you. --force does exactly that, and it is named so that it reads like one.

What it rejects

Paths are validated before they reach the filesystem, and rejected rather than cleaned up:

  • forward slashes only, no backslashes, no null bytes, not absolute
  • must end in .md
  • no segment may start with a dot, since the watcher ignores dot directories and the write would look like it worked while never compiling
  • 200 characters maximum, because Windows caps a path at 260
  • 1,000,000 bytes per concept

Bundle names match [a-z0-9][a-z0-9_-]{0,63}, the same rule as a tenant id, because both become directories.

On this page