Build an app on Datanotes
This tutorial builds film night, a small app for the diary of classic films. The app is TypeScript on the HTTP API, with the JavaScript client and its own token, and every answer on this page comes from a real run against the engine. By the end, film night does three things:
- Reads the films you still want to watch
- Logs a viewing as one undoable change
- Prints a line each time a film becomes watched
Before you start
Section titled “Before you start”You need:
- Node.js 24 or newer, which runs
.tsfiles directly - An engine that runs on your notes, with
npx datanotes serve --database your_database_folder - The diary’s tables:
filmandwatchas in Read and filter rows, with the films of that page - The
datanotespackage in the app’s folder:npm install datanotes, with"type": "module"in itspackage.json
The app reads a view of the films to watch, and the engine refuses a watched film without a rating. Create both once with the owner token:
await owner.call("create_view", { name: "vw_to_watch", query: { table: "film", columns: ["title", "year", "minutes", "director"], where: { status: "to watch" }, order_by: "minutes", },});await owner.call("add_constraint", { table: "film", name: "rated_when_watched", check: { or: [{ status: { ne: "watched" } }, { rating: { exists: true } }], },});owner is a DatanotesClient with the owner token, as in step 1. Store a query as a view and Enforce rules with CHECK constraints explain both operations.
Build film night
Section titled “Build film night”The app has four small files in src/: the client, the films, the change feed and the entry point.
-
Create the app’s token with the owner token. The engine answers with the token, once, and keeps only its hash:
import { DatanotesClient } from "datanotes/client";const owner = new DatanotesClient({url: "http://127.0.0.1:27150",token: "your_owner_token",});const r = await owner.post<{ token: string }>("/tokens/create/", {name: "film-night",description: "Logs film nights and picks the next film",write: ["main.public.watch", "main.public.film"],});console.log(r.token);The token can read every table, and write rows of
watchandfilmonly. It can’t change the schema, undo, or touchactor. The engine lists it like this:{"name": "film-night","description": "Logs film nights and picks the next film","write": ["main.public.watch", "main.public.film"],"ddl": false}Keep the token in an environment variable,
FILM_NIGHT_TOKEN. -
Create the client in
src/db.ts. Every other file imports it:import { DatanotesClient } from "datanotes/client";export const db = new DatanotesClient({url: process.env.DATANOTES_URL ?? "http://127.0.0.1:27150",token: process.env.FILM_NIGHT_TOKEN ?? "",actor: { harness: "film-night" },});The engine records
film-nightas theauthof every write this token makes, whateveractorsays. -
Read the films to watch in
src/films.ts. You select a view like a table:import { db } from "./db.ts";export interface Film {path: string;title: string;minutes: number;}export async function filmsToWatch(): Promise<Film[]> {const r = await db.call("select_rows", { table: "vw_to_watch" });if (!r.ok) throw new Error(String(r.value));return (r.value as { rows: Film[] }).rows;}The view answers with the two films still to watch, shortest first. Each row includes its note’s
path, which the next step uses:[{"path": "main/public/film/Battleship Potemkin.md","title": "Battleship Potemkin","year": 1925,"minutes": 75,"director": "Sergei Eisenstein"},{"path": "main/public/film/Metropolis.md","title": "Metropolis","year": 1927,"minutes": 153,"director": "Fritz Lang"}] -
Log a viewing, also in
src/films.ts. Abatchinserts thewatchrow and updates the film together:export interface Viewing {at: string;where: "cinema" | "home" | "outdoor screening";place: string;rating?: number;}export function logViewing(film: Film, v: Viewing) {const { at, where, place, rating } = v;const watch = { film: `[[${film.title}]]`, occurred_at: at, where, place };const seen = { status: "watched", rating, watched_on: at.slice(0, 10) };return db.call("batch", {summary: `Film night: ${film.title}`,operations: [{ op: "insert_row", table: "watch", values: watch },{ op: "update_row", table: "film", path: film.path, set: seen },],});}The engine checks both operations before it writes either. When it refuses one, it writes nothing. The batch is one entry in the journal, so one undo reverts both.
-
Follow the films you mark watched, in
src/feed.ts. The app long-polls the change feed withlist_changes, keeping onlyfilmchanges tostatusthat leave itwatched:import { db } from "./db.ts";interface Change {row: { title: string; rating?: number };}type Page = { events: Change[]; last: number; head: number };const watched = {tables: ["film"],columns: ["status"],where: { status: "watched" },};async function changes(args: object): Promise<Page> {const r = await db.call("list_changes", { ...watched, ...args });if (!r.ok) throw new Error(String(r.value));return r.value as Page;}followWatchedstarts athead, the feed’s latest change, so it reports only what happens after the app starts. Each call waits up to 20s for a change, andlastis where the next one starts:export async function followWatched(onWatched: (c: Change) => void) {let since = (await changes({ limit: 1 })).head;for (;;) {const page = await changes({ since, wait_ms: 20_000 });page.events.forEach(onWatched);since = page.last;}}The feed suits an app better than a webhook here: a named token can read the feed, while only the owner creates webhooks. To pick up where it stopped after a restart, save
lastand start from it. -
Start the app in
src/main.ts. It follows the feed and prints each film you mark watched:import { followWatched } from "./feed.ts";await followWatched(({ row }) => {console.log(`${row.title} is watched, rated ${row.rating ?? "-"}`);});Run it and leave it running:
Terminal window node src/main.ts
Let agents pick the film
Section titled “Let agents pick the film”The engine serves no tools of other programs. For agents to pick tonight’s film with one call, film night serves its own Model Context Protocol (MCP) server, with a pick_film_for_tonight tool next to the engine’s read tools and instructions of its own. Agents connect to it as to any MCP server. Build your own MCP server writes that server for this diary, with datanotes/mcp.
Log tonight’s film
Section titled “Log tonight’s film”src/tonight.ts logs the evening with logViewing, and prints the batch’s journal entry:
import { filmsToWatch, logViewing } from "./films.ts";
const films = await filmsToWatch();console.log(films.map((f) => `${f.title}, ${f.minutes} min`));
const potemkin = films.find((f) => f.title === "Battleship Potemkin")!;const r = await logViewing(potemkin, { at: "2026-10-09T21:00:00", where: "cinema", place: "the film club downtown", rating: 5,});if (!r.ok) console.log(r.value);else console.log((r.value as { migration_id: string }).migration_id);The script prints the films to watch and the batch’s id:
[ 'Battleship Potemkin, 75 min', 'Metropolis, 153 min' ]20261009T215615-batch-film-night-battleship-potemkinA moment later, the running app prints the change it read from the feed:
Battleship Potemkin is watched, rated 5The diary now has a watch note, main/public/watch/2026-10-09T2100.md, and Metropolis is the only film left to watch.
To revert the whole evening, the owner calls undo_migration with the batch’s id. The app can’t: its token answers token film-night may not call undo_migration (reserved to the owner).
Show the engine’s refusals
Section titled “Show the engine’s refusals”When the engine refuses a write, ok is false and value says why, in words a person can read. The app prints it as it is. This script logs Metropolis twice, first without a rating, then with a rating of 6:
const metropolis = (await filmsToWatch()) .find((f) => f.title === "Metropolis")!;const evening = { at: "2026-10-10T20:30:00", where: "home" as const, place: "my living room",};
for (const rating of [undefined, 6]) { const r = await logViewing(metropolis, { ...evening, rating }); if (!r.ok) console.log(r.value);}The first breaks the rated_when_watched CHECK, the second the column’s max: 5. In both cases the engine undoes the watch row of operation 0, and nothing stays written:
batch refused at operation 1 — the operations before it were undone, nothing is left written:operation 1 (update_row): update_row refused — nothing was written:invalid main/public/film/Metropolis.md: rated_when_watched violates CHECK rated_when_watched: {"or":[{"status":{"ne":"watched"}},{"rating":{"exists":true}}]}batch refused at operation 1 — the operations before it were undone, nothing is left written:operation 1 (update_row): update_row refused — nothing was written:invalid main/public/film/Metropolis.md: rating must be <= 5A write outside the app’s permissions comes back the same way. The app may not write actor:
const r = await db.call("update_rows", { table: "actor", where: { name: "Brigitte Helm" }, set: { born: "1906-03-17" },});if (!r.ok) console.log(r.value);The engine answers with 403, and the client turns it into the reason:
token film-night may not write main.public.actorLearn more about tokens, the client and the feed
Section titled “Learn more about tokens, the client and the feed”These pages cover each part of film night in full:
- Give each program its own token: every permission and route of named tokens
- Build on Datanotes: the ways to build on the engine, your own MCP server included
- Call the engine from JavaScript: the client’s methods and errors
- Follow every change with the change feed: the feed’s filters, streaming and webhooks