English
An app on top of trip2g
A custom layout doesn't have to render a page. It can ship a JavaScript app that shows a note as something interactive and writes the changes back. The note stays plain markdown: Obsidian, an agent and the app all edit the same file.
Think of it as an interactive view app: a note rendered as an app, with markdown files as its database. One note is the app's home, and the app can read and write many more.
The Kanban board template works this way. It turns an obsidian-kanban note into a drag-and-drop board, and every move is saved back into the note's markdown. This page describes the pattern it follows, so you can build your own app: a checklist, a table editor, a form builder, a dashboard.
New to layouts? Read Templates first.
The three parts
| Part | Where it lives | What it does |
|---|---|---|
| Layout | _layouts/app.html in the vault |
Puts the note's data into the page, a mount element, and a <script> that loads the app |
| App bundle | Next to the layout, or any URL | Reads the data from the page, renders the UI |
| GraphQL API | /_system/graphql on your site |
Saves the edited markdown back into the note |
A note opts in with layout: app in its frontmatter, like any other layout.
How your app is authorised: the session cookie
The app has no login of its own. It borrows the visitor's trip2g session:
- The visitor signs in with the button in the standard header. The layout puts trip2g's header on the page, see Wrap your app in the standard site chrome. After a successful sign-in the page reloads.
- The server sets a session cookie. It is
HttpOnly(page JavaScript can't read it),SameSite=Lax, and belongs to the host the visitor signed in on. - The app's own requests carry it. A
fetch('/_system/graphql', …)from the page goes to the same origin, and the browser attaches the cookie by itself. The server reads it and treats the request as that user's: a signed-in admin may read and write notes.
So the reloaded page is rendered for the signed-in user: currentUser.IsAdmin() is now true for the admin, and the app's editable flag with it.
Do:
- Call the API with a relative URL,
/_system/graphql, from a page the site itself serves: the layout and itsasset()files, or a bundle that layout loads. - Leave
fetchcredentials at the default (same-origin) or setcredentials: 'include', as the helpers on this page do; for a same-origin request the two are the same. Never setcredentials: 'omit'. - Send operations as
POSTwithContent-Type: application/json; a file upload goes asmultipart/form-data. The endpoint runs no operation sent another way. - Decide whether to show edit controls from
currentUser.IsAdmin()in the layout, passed to the app as a flag such aseditable.
Don't:
- Don't put an API key or a personal token in the page. Anyone who opens the page can read them. The cookie is the credential.
- Don't build a login screen. Signing in is the header's job; after it the page reloads with the session in place.
- Don't serve the app from another origin, such as a separate dev server. The browser doesn't send the
SameSite=Laxcookie with a cross-sitePOST, and the server allows cross-origin requests only from the Obsidian plugin.
No CSRF token, no extra header. The endpoint asks for nothing beyond the cookie: no CSRF token and no custom header. A form on another site can't use the session, because the browser leaves the SameSite=Lax cookie off a cross-site POST.
A visitor who isn't signed in reaches the page and the app normally. currentUser.IsAdmin() is false, so editable is false: render the note read-only and point to the header's sign-in button, for example with a "Sign in to edit" line. On the API, search still works; a write returns an errors entry. A reader who is signed in but isn't the admin gets the same refusal on writes. The table in Ready-made GraphQL operations lists who reaches what.
The layout: ship the note in the page
The layout gives the app three things: the note's vault path, its raw markdown, and whether the visitor may edit. json() puts them into a <script type="application/json"> element:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
</head>
<body>
<div id="app"></div>
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin()
)) }}
</script>
<script src="{{ asset("app.js") }}"></script>
</body>
</html>
titleis a variable every custom layout gets: the note's title run through the site's title template, ready for<title>. With the default template,%s, it is the title itself. For the bare title elsewhere on the page, usenote.Title().note.ContentString()is the note's markdown source, frontmatter included. It is the same text the server hashes for conflict checks, so the app can edit it and send it back.json()escapes<,>and&as<…, so markdown that contains</script>can't close the element.JSON.parsegives back the exact text.currentUser.IsAdmin()only decides whether the app shows its edit controls. The server checks every save again (see below).- Calling
currentUsermakes the page personalized: it is not served from the anonymous page cache, so the app always starts from the current version of the note.
The kanban layout uses a different carrier for the same data: the markdown goes into a hidden <textarea> and the path into a hidden <span>, both printed with plain {{ … }}. That is safe too, because output is escaped by default: a note containing </textarea> reaches the page as </textarea>, and the browser decodes it back. A <textarea> has two quirks, though: the browser normalizes its line endings to \n and drops a newline that comes right after the opening tag. The JSON island has neither.
Configure the app from the frontmatter
The note's frontmatter is a handy place for the app's settings. Whoever edits the note, in Obsidian or by an agent, changes them along with the content, and one bundle serves many notes, each with its own settings:
---
title: Todo
layout: app
columns: [Todo, Doing, Done]
wip_limit: 3
show_archive: false
---
The layout reads the keys with note.M() and adds them to the data block as config:
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin(),
"config", map(
"columns", note.M().GetStrings("columns"),
"wipLimit", note.M().GetInt("wip_limit", 0),
"showArchive", note.M().GetBool("show_archive", true)
)
)) }}
</script>
The app finds them in data.config:
const { columns, wipLimit, showArchive } = data.config
Each getter returns its default when the key is missing or holds the wrong type, so a note without settings still opens; GetStrings returns an empty list. To hand the app the whole frontmatter instead, use "config", note.M().Raw(). The getters are in Templates. The settings are also part of content, so a save that keeps the frontmatter keeps them.
Wrap your app in the standard site chrome
The layout above is a bare page. To give the app the site's header, with its sign-in button, and its footer, print the default template's parts around the mount element:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
{{ defaultTemplate.Styles() }}
{{ defaultTemplate.UserSpaceScripts() }}
</head>
<body>
{{ defaultTemplate.Header() }}
<main id="app"></main>
<script type="application/json" id="app-data">
{{ json(map(
"path", note.Path(),
"content", note.ContentString(),
"editable", currentUser.IsAdmin()
)) }}
</script>
<script src="{{ asset("app.js") }}"></script>
{{ defaultTemplate.Footer() }}
</body>
</html>
| Call | Prints |
|---|---|
defaultTemplate.Styles() |
<link> tags for the default template's stylesheet, so the header and footer look as they do on other pages |
defaultTemplate.UserSpaceScripts() |
A <script> with the bundle's settings, then the user-space bundle. The bundle draws the sign-in button and the search box in the header. The settings come only on the first call |
defaultTemplate.Header() |
The site header: logo, navigation, the sign-in button and the search box |
defaultTemplate.Footer() |
The site footer |
Header() and Footer() print nothing when the page has no header or footer note. The note comes from the page's header: / footer: frontmatter, a matching layout section, or a _header / _footer note in the vault: see Functional notes. Without a header there is no sign-in button either. To keep one, put the bundle's mount element where you want the button, with UserSpaceScripts() still in <head>:
<div mol_view_root="$trip2g_user_space"></div>
In the layout preview (/_system/renderlayout) all four calls print nothing. The full list of what a layout gets is in Jet functions → Variables in every custom layout.
The bundle
Two ways to serve the JavaScript:
- Next to the layout. Put
app.jsbeside_layouts/app.htmland load it with{{ asset("app.js") }}, as above. The URL carries a cache-busting hash. See asset(). - From a release. The kanban layout loads its bundle from a GitHub release URL,
…/releases/latest/download/kanban.js. Users install one HTML file and get updates without syncing anything.
The bundle can be any framework or none. Kanban is a React app built with esbuild into one self-contained file.
The app reads its data on start:
const data = JSON.parse(
document.getElementById('app-data').textContent
)
// data.path, data.content, data.editable
Lists of other notes
An app that shows other notes, such as posts with a tag or tasks from a folder, asks for them with a note query: a Jsonnet program in your vault that returns exactly the JSON the app needs, run with the visitor's access.
Saving: the updateNotes mutation
The app writes the note back with the updateNotes GraphQL mutation at /_system/graphql. A signed-in site admin is allowed to write; anyone else gets an error, so a visitor's browser can't change the note even if the app shows its controls by mistake.
Every operation is the same POST with a JSON body { query, variables }, so one small helper covers them all. makeRequest takes an operation and returns a function of its variables:
const makeRequest = (query) => (variables) =>
fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
.then((res) => res.json())
.then((body) => {
if (body.errors) throw new Error(body.errors[0].message)
return body.data
})
const updateNotes = makeRequest(`mutation ($input: UpdateNotesInput!) {
updateNotes(input: $input) {
__typename
... on UpdateNotesSuccessPayload {
updated { path versionId warnings { level message } }
}
... on UpdateNotesHashMismatchPayload { path actualHash }
... on UpdateNotesPatchNotFoundPayload { path find }
... on ErrorPayload { message }
}
}`)
const change = { upsert: { path: data.path, content: newContent, expectedHash } }
const { updateNotes: result } = await updateNotes({ input: { changes: [change] } })
newContent is the edited markdown, and expectedHash is explained below. The request is same-origin, so the browser attaches the session cookie; credentials: 'include' only says so explicitly. __typename says how the save went:
__typename |
Meaning |
|---|---|
UpdateNotesSuccessPayload |
Saved; updated lists each note with the versionId the save wrote and its warnings |
UpdateNotesHashMismatchPayload |
Nothing was written: the note no longer matches expectedHash; actualHash is its current hash |
UpdateNotesPatchNotFoundPayload |
A patch change didn't find its find text, or found it more than once |
ErrorPayload |
Refused, with a message |
A visitor who isn't the admin doesn't reach a payload at all: the response carries an errors entry and no data, which makeRequest turns into an exception.
expectedHash: a save applies once, to the text it was made from
expectedHash is a field of the upsert and patch changes of updateNotes. It ties a change to the version of the note it was made from. Before writing, the server hashes the note's current markdown and compares:
- It matches: the change is written.
- It doesn't: nothing is written, and the result is
UpdateNotesHashMismatchPayloadwith the note'spathand its currentactualHash.
That makes a change apply at most once. The same save sent twice, by a retry after a timeout, a double click or a second open tab, carries the same expectedHash; the first one changes the note, so the second no longer matches and is refused instead of applied again. The same check keeps the app from overwriting an edit someone made in Obsidian or by an agent in between.
What to pass is the hash of the markdown the app started from: latestContentHash from the ReadNote query below, or the hash of data.content the layout shipped. The hash is SHA-256 of the markdown, in URL-safe base64 with padding:
async function contentHash(text) {
const bytes = new TextEncoder().encode(text)
const digest = await crypto.subtle.digest('SHA-256', bytes)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
}
const expectedHash = await contentHash(data.content)
A successful save doesn't return the new hash. The note now holds exactly the markdown an upsert sent, so the next save's expectedHash is contentHash(newContent); after a patch, read latestContentHash again. Two values are special: an empty expectedHash means "create only" and fails on a note that exists, and a change without expectedHash is written unconditionally.
On UpdateNotesHashMismatchPayload reload the page, or fetch the new content and apply your change to it. Kanban re-reads the latest version through the admin noteVersionHistory and noteVersion queries and replays the move.
Small edits. Instead of upsert, which replaces the whole note, a change can be a patch: { patch: { path, find, replace, expectedHash } } replaces one exact piece of text. Kanban toggles a checkbox this way, so text it doesn't model is never rewritten. To change frontmatter, use a meta change: { meta: { path, set, unset, expectedHash } } sets or removes top-level keys and leaves every other line of the note as it was, so the app doesn't need the old value's exact text. All three forms, batches and error cases are in updateNotes.
Validators: keep the app's data in shape
The note is the app's database, and anyone can edit it: you in Obsidian, an agent, the app itself. Nothing stops a broken value from landing in the file, and the app doesn't fail on it. columns: Todo, Doing, Done without brackets is one string, so GetStrings returns a single column named "Todo, Doing, Done". wip_limit: three is a word, not a number, so GetInt returns its default. The board opens, looks wrong, and nobody sees why. Attach a validator to every note the app reads as data, and such a mistake becomes a warning on the note.
A validator for the settings from Configure the app from the frontmatter, in a note validators/app_config.md:
local isInt(v) = std.isNumber(v) && std.floor(v) == v;
local columns = std.get(meta, 'columns');
(
if !std.isArray(columns) || std.length(columns) == 0
then [{level: 'error', msg: 'columns must be a non-empty list'}]
else []
) + (
if std.objectHas(meta, 'wip_limit') && !isInt(meta.wip_limit)
then [{level: 'error', msg: 'wip_limit must be a whole number'}]
else []
) + (
if std.objectHas(meta, 'show_archive')
&& !std.isBoolean(meta.show_archive)
then [{level: 'warning', msg: 'show_archive must be true or false'}]
else []
)
The data note names it in its frontmatter:
validate: "[[app_config]]"
A validator doesn't block a save. The note is written even with an error, and the messages come back in updated[].warnings of the updateNotes response, together with the note's other warnings. level is CRITICAL for an error and WARNING for a warning. Check them after every save and show them to the user, or a broken value the app itself wrote goes unnoticed:
if (result.__typename === 'UpdateNotesSuccessPayload') {
const messages = result.updated
.flatMap((note) => note.warnings)
.map((w) => `${w.level}: ${w.message}`)
if (messages.length > 0) alert(messages.join('\n'))
}
alert is the shortest way; a real app shows the messages in its own UI. The same messages appear in the sync CLI and in the admin panel, see Where you see the result.
Live updates
To pick up edits made in Obsidian or by an agent while the app is open, subscribe to noteChanges with a filter on the note's path. The endpoint serves subscriptions over server-sent events only, on the same /_system/graphql: a POST with Accept: text/event-stream. It has no WebSocket transport, so a graphql-ws client won't connect. EventSource can't send a POST, so the helper reads the response stream itself:
const makeSubscription = (query) => async (variables, onData) => {
const res = await fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify({ query, variables }),
})
const reader = res.body
.pipeThrough(new TextDecoderStream())
.getReader()
let buffer = ''
for (;;) {
const { done, value } = await reader.read()
if (done) return
buffer += value
const frames = buffer.split('\n\n')
buffer = frames.pop()
for (const frame of frames) {
const line = frame.match(/^data: (.*)$/m)
if (!frame.startsWith('event: next') || !line) continue
const body = JSON.parse(line[1])
if (body.errors) throw new Error(body.errors[0].message)
onData(body.data)
}
}
}
const watchNotes = makeSubscription(`subscription ($filter: NoteChangesFilter!) {
noteChanges(filter: $filter) {
changes {
__typename
... on NoteUpsertEvent { path versionId }
... on NoteHideEvent { path }
}
}
}`)
async function follow(path, onChange) {
for (;;) {
await watchNotes({ filter: { includePatterns: [path] } }, onChange)
.catch(() => {})
await new Promise((resolve) => setTimeout(resolve, 5000))
}
}
const ownVersions = new Set()
follow(data.path, ({ noteChanges }) => {
const foreign = noteChanges.changes
.some((change) => !ownVersions.has(change.versionId))
if (foreign) location.reload()
})
The server sends event: next with a result, event: complete at the end, and a : ping comment every 30 seconds. The stream can end, for example when the server restarts, so follow subscribes again after a pause.
Your own save comes back as an event too. Add each versionId from the save's updated to ownVersions, and the app skips its own echo. location.reload() is the simplest reaction to someone else's edit. To keep the app's state, re-read the note with ReadNote and merge instead, as Kanban's src/api.ts does. The subscription needs a signed-in visitor: the admin gets every matching note, another signed-in reader only the notes they may read. The filter's globs are explained under Watch for changes.
Ready-made GraphQL operations
Copy these into your app. Each one goes to /_system/graphql as a POST with a JSON body { query, variables }: the operation is query, the JSON block under it is variables. Wrap an operation with makeRequest from Saving and call it with the variables. Here READ_NOTE holds the ReadNote operation below:
const readNote = makeRequest(READ_NOTE)
const { notePaths } = await readNote({ paths: [data.path] })
Most results are unions: ask for __typename and one ... on fragment per type, then branch on __typename. A refusal the app should show comes back as a payload type such as ErrorPayload; a caller with no access gets an errors entry instead, which makeRequest turns into an exception.
Who may call what. The details are in updateNotes → Authentication.
| Caller | Sends | Reaches |
|---|---|---|
| Signed-in admin | Session cookie, or Authorization: Bearer t2g_… |
Everything below, admin { … } included |
| API key | X-Api-Key: … |
Everything but admin { … }; writes only within the key's write patterns |
| Webhook token | Authorization: Bearer eyJ… |
What an API key reaches, within the token's read and write patterns, except hideNotes, pushNotes and commitNotes |
| Any visitor | Nothing | search, submitForm, and noteChanges once signed in |
An app on a page visitors open authenticates with the admin's session cookie, which credentials: 'include' sends. Don't put an API key in a bundle: anyone who loads the page can read it.
Read a note's markdown
query ReadNote($paths: [String!]) {
notePaths(filter: { paths: $paths }) {
value
content
latestContentHash
}
}
{ "paths": ["boards/todo.md"] }
value is the vault path and content the markdown with its frontmatter. latestContentHash is the hash expectedHash takes, so the app doesn't have to compute it. A path that doesn't exist is missing from the list.
List notes
query ListNotes($filter: NotePathsFilter) {
notePaths(filter: $filter) {
value
latestContentHash
latestNoteView {
title
url
}
}
}
{ "filter": { "like": "boards/%" } }
{ "filter": { "frontmatter": [{ "key": "layout", "equals": "app" }] } }
like is an SQL LIKE pattern: % matches any run of characters, _ one character. The filter also takes search (full-text search over notes) and paths; when several are set, paths wins over search, and search over like. frontmatter narrows any of them. With no filter you get every path in the vault that isn't hidden.
Search
query Search($input: SearchInput!) {
search(input: $input) {
totalCount
nodes {
url
highlightedTitle
highlightedContent
document {
... on PublicNote {
path
notePath
title
}
}
}
}
}
{ "input": { "query": "release plan" } }
The site's own search box, open to any visitor. Notes the visitor can't read come last, with a title and a URL but no document. An admin searches the latest versions; others search what is published, unless the site shows drafts to everyone. path is the note's URL path (/people/anna); notePath is its path in the vault (people/Anna.md), the one notePaths and updateNotes take.
Save a whole note
mutation SaveNotes($input: UpdateNotesInput!) {
updateNotes(input: $input) {
__typename
... on UpdateNotesSuccessPayload {
updated {
path
versionId
warnings {
level
message
}
}
}
... on UpdateNotesHashMismatchPayload {
path
actualHash
}
... on UpdateNotesPatchNotFoundPayload {
path
find
}
... on ErrorPayload {
message
}
}
}
{
"input": {
"changes": [
{
"upsert": {
"path": "boards/todo.md",
"content": "# Todo\n\n- [ ] Ship it\n",
"expectedHash": "latestContentHash from ReadNote"
}
}
]
}
}
upsert replaces the note or creates it. Pass expectedHash and the save happens only if nobody changed the note since you read it; otherwise you get UpdateNotesHashMismatchPayload. An empty expectedHash means "create only": the save fails if the note already exists. Without expectedHash the save always overwrites. updated[].versionId is the version the save wrote. updated[].warnings are the note's warnings after the save, validator messages included: level is INFO, WARNING or CRITICAL. A note with a CRITICAL warning is still saved, so show the messages to the user.
Change one piece of text
The same SaveNotes mutation, with a patch change:
{
"input": {
"changes": [
{
"patch": {
"path": "boards/todo.md",
"find": "- [ ] Ship it",
"replace": "- [x] Ship it",
"expectedHash": "latestContentHash from ReadNote"
}
}
]
}
}
find must occur in the note exactly once. If it is missing, or appears more than once, the note is left as it is and the result is UpdateNotesPatchNotFoundPayload. One call can carry several changes, for several notes; see updateNotes.
Change frontmatter keys
The same SaveNotes mutation, with a meta change. It sets status and priority and removes due; the note's other keys, their order and comments, and its text stay as they are:
{
"input": {
"changes": [
{
"meta": {
"path": "boards/todo.md",
"set": { "status": "done", "priority": 2 },
"unset": ["due"]
}
}
]
}
}
From the app, with updateNotes from Saving:
async function setMeta(path, set, unset = []) {
const { updateNotes: result } = await updateNotes({ input: { changes: [{ meta: { path, set, unset } }] } })
if (result.__typename !== 'UpdateNotesSuccessPayload') {
throw new Error(result.message || result.__typename)
}
}
await setMeta('boards/todo.md', { hidden: true })
await setMeta('boards/todo.md', {}, ['hidden'])
Only top-level keys are addressed. Frontmatter that can't be edited key by key (invalid YAML, a {...} flow mapping, a duplicate key) returns ErrorPayload and writes nothing. expectedHash is optional and works as for patch. The rules are in updateNotes.
Hide notes
mutation HideNotes($input: HideNotesInput!) {
hideNotes(input: $input) {
__typename
... on HideNotesPayload {
success
}
... on ErrorPayload {
message
}
}
}
{ "input": { "paths": ["boards/old.md"] } }
A hidden note disappears from the site. Saving it again with updateNotes brings it back. A hide change in updateNotes, { "hide": { "path": "boards/old.md" } }, does the same within a batch.
The hide is recorded against an admin: the signed-in admin, the admin who created the API key, or the admin who created the webhook that issued the token. A webhook token gets ErrorPayload from hideNotes; it hides with the hide change, within its write patterns.
Upload a file
A file goes in as a multipart/form-data request, in the GraphQL multipart request format: an operations part with the query and variables, a map part that says which variable the file fills, and the file itself.
mutation UploadAsset($input: UploadNoteAssetInput!) {
uploadNoteAsset(input: $input) {
__typename
... on UploadNoteAssetPayload {
uploadSkipped
}
... on ErrorPayload {
message
}
}
}
async function sha256Hex(blob) {
const bytes = await blob.arrayBuffer()
const digest = await crypto.subtle.digest('SHA-256', bytes)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}
async function upload(file, noteId, path, absolutePath) {
const input = {
file: null,
noteId,
sha256Hash: await sha256Hex(file),
path,
absolutePath,
}
const form = new FormData()
form.append('operations', JSON.stringify({
query: UPLOAD_ASSET,
variables: { input },
}))
form.append('map', JSON.stringify({ 0: ['variables.input.file'] }))
form.append('0', file, file.name)
const res = await fetch('/_system/graphql', {
method: 'POST',
credentials: 'include',
body: form,
})
const body = await res.json()
if (body.errors) throw new Error(body.errors[0].message)
return body.data.uploadNoteAsset
}
UPLOAD_ASSET is the operation above. Don't set Content-Type yourself: the browser adds it with the multipart boundary.
A file belongs to a note version, so save the markdown that links to it first:
- Save the note with
SaveNotes, its markdown containing, say,. - Take
versionIdfromupdatedand pass it asnoteId. - Pass
pathexactly as the markdown writes the link (plan.png), andabsolutePathas the file's path in the vault (boards/plan.png).
sha256Hash is the file's SHA-256 in hex, not the base64 of expectedHash; the server checks it after the upload. A path the note doesn't link to is refused with an ErrorPayload that lists the links it has. uploadSkipped: true means the server already had this file and only linked it to the new version.
Watch for changes
subscription WatchNotes($filter: NoteChangesFilter!) {
noteChanges(filter: $filter) {
changes {
__typename
... on NoteUpsertEvent {
path
eventType
versionId
}
... on NoteHideEvent {
path
}
}
}
}
{ "filter": { "includePatterns": ["boards/**"] } }
includePatterns (required) and excludePatterns are globs: * stays within a folder, ** crosses folders, and a plain path matches one note. One event can carry several changes when one save wrote several notes.
The client that runs this subscription, with reconnects, is in Live updates.
Read an older version (admin)
Kanban rebuilds a move on top of someone else's edit with these two. Versions come newest first, so limit: 1 gives the latest.
query NoteVersions($filter: AdminNoteVersionHistoryFilter!) {
admin {
noteVersionHistory(filter: $filter) {
nodes {
versionId
version
createdAt
}
}
}
}
{ "filter": { "path": "boards/todo.md", "limit": 1 } }
query NoteVersion($id: Int64!) {
admin {
noteVersion(versionId: $id) {
path
content
createdAt
}
}
}
{ "id": 42 }
API calls and admin calls
The endpoint carries two kinds of operations, and they differ in what they promise:
| API calls | Admin calls | |
|---|---|---|
| Where they sit | At the top level of the operation: notePaths, search, updateNotes, noteChanges |
Inside admin { … }, the AdminQuery and AdminMutation types in the schema |
| Who relies on them | The Obsidian plugin, agents with an API key, apps like this one | trip2g's own admin panel |
| Stability | The surface those clients are built on | Fewer guarantees: they change with the admin panel and may change in any release |
| Who may call | See the table in Ready-made GraphQL operations | A signed-in admin: the session cookie or a personal token |
An app may use admin calls; Kanban reads version history with them. Keep them to features only admins see, and check them again after you upgrade trip2g. Everything else on this page, apart from the section marked "(admin)", is API.
The API surface is documented in updateNotes, GraphQL API and the operations above, and the Obsidian plugin's operations file below lists what it runs.
An app over the whole knowledge base: MCP from the browser
An app doesn't have to be about the note it is on. MCP Graph Walk shows a model searching and reading this documentation site live, every call a step on an animated map. Its page is one note, docs/search_visualizer.md with layout: search_visualizer, and the layout, docs/_layouts/search_visualizer.html, is the whole app. It:
- calls
/_system/mcpfrom the browser with JSON-RPCtools/call:search,note_html,expand,similarand their federated versions; - reads the knowledge graph of the whole site and writes nothing;
- runs full-screen with its own header, without
defaultTemplate.Header(); - talks to a model with an API key the visitor pastes. The page keeps the key in the browser's
localStorageand sends it only to the model provider; trip2g never sees it.
| The note is | The app talks to | |
|---|---|---|
| Kanban | The data: the app edits the note it is on | GraphQL: updateNotes, noteChanges |
| Graph walk | The app's home: the data is the whole site | MCP: search, note_html |
MCP or GraphQL. Use MCP to search and read across the base: search results with snippets and a match_id per hit, one section read by its toc_path, a note's table of contents walked with expand, related notes with similar, and search across connected bases. It is the same tool surface agents use, so a page that shows what an agent would find is built on it. The tools are described in MCP Server. Use GraphQL for everything else: writes (updateNotes), live updates (noteChanges), typed queries that return exactly the fields you ask for, and viewer.
A call. The endpoint is stateless, so a tools/call works without an initialize first. Send initialize only to read the site's instructions for agents, and tools/list to get each tool's input schema; the graph walk sends both because it hands the schemas to the model.
let rpcId = 0
async function callTool(name, args) {
const res = await fetch('/_system/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: ++rpcId,
method: 'tools/call',
params: { name, arguments: args },
}),
})
const body = await res.json()
if (body.error) throw new Error(body.error.message)
if (body.result.isError) throw new Error(body.result.content[0].text)
return body.result
}
const found = await callTool('search', { query: 'release plan', limit: 5 })
const hits = found.structuredContent.results
const section = await callTool('note_html', { path: hits[0].note_path })
const html = section.content.map((part) => part.text).join('\n')
Each hit in structuredContent.results has title, note_path, url and matches; content holds the same answer as text, and for note_html that text is the note's HTML. The request must be a POST with Content-Type: application/json; anything else gets HTTP 415. Leave Accept at the browser's default: Accept: application/json alone is refused with HTTP 400, because the endpoint wants application/json, text/event-stream or */*.
Access. MCP reads the same session cookie as GraphQL. The request goes to the same origin, the browser attaches the cookie, and the server checks it before it runs a tool:
- A visitor who isn't signed in finds and reads only the notes open to everyone. Search leaves the rest out entirely, unlike GraphQL
search, which lists them last without adocument. - A signed-in reader also reaches the notes of the subgraphs they have access to: the same notes they could open on the site.
- A signed-in admin reaches every note.
- A note the visitor can't read answers
note_htmlwith the errorNote not found, the same as a note that doesn't exist. - Nothing the browser sends unlocks a write. The tools that run GraphQL as admin,
graphql_requestandgraphql_introspection, need an API key with MCP admin tools turned on; a session cookie, even the admin's, doesn't reach them. From a page, MCP is read-only.
Call it from a page on the same site. The server sends CORS headers only to the Obsidian plugin, so a page on another origin can't read the answers.
Your own API next to trip2g, with trip2g as the access check
An app may need more than notes: a report built from another database, a call to a paid service, a job that runs for a minute. Put that in an API of your own and let trip2g decide who may call it. The API asks trip2g on the user's behalf, so it keeps no users, no passwords and no access rules of its own.
Mount it on the same host. The session cookie, trip2g_token, is HttpOnly, Secure, SameSite=Lax with Path=/, and has no Domain attribute. Without Domain the browser sends it only to the exact host that set it: not to a subdomain, not to a parent domain. So the API lives under a path on the site's own host, not on api.example.com.
Under /_system/extra/. This prefix is reserved for the site owner: trip2g serves nothing under it and never will, and a test in trip2g's router fails if one of its routes starts with it. Mount each API at /_system/extra/<name>, such as https://notes.example.com/_system/extra/report, and route /_system/extra/ to your APIs in the reverse proxy in front of trip2g; everything else keeps going to trip2g. Two nearby choices are not safe:
/api/…is also a note path: a noteapi/report.mdis served at/api/report, and the proxy rule would hide it./_system/<name>is where trip2g's own routes live:/_system/graphql,/_system/mcp,/_system/admin,/_system/auth/…and more. A later release may add one with your name.
No note is ever served under /_system/, so the prefix can't hide content either. A request to /_system/extra/… that reaches trip2g, because no proxy rule took it, gets the site's "Page not found" page with HTTP 404, for GET and POST alike.
The flow.
- The app calls
fetch('/_system/extra/report', …). The request is same-origin, so the browser attaches the cookie. - The API takes the request's
Cookieheader and sends it unchanged to trip2g's/_system/graphqlover the internal network, with a query that asks who this is. - trip2g answers as it would answer the browser. The API decides from that answer, and refuses whenever trip2g refused.
The layout gives the app the note's id, adding "pathId", note.PathID() to the data block from the layout, and the app sends it:
const res = await fetch('/_system/extra/report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pathId: data.pathId }),
})
if (!res.ok) throw new Error(`report: ${res.status}`)
const report = await res.json()
The API, here in Node 18 or later, asks trip2g who the caller is:
import { createServer } from 'node:http'
const askTrip2g = (query) => async (cookie, variables) => {
const res = await fetch(process.env.TRIP2G_GRAPHQL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ query, variables }),
})
const body = await res.json()
return body.errors ? null : body.data
}
const whoIs = askTrip2g(`query { viewer { role user { email } } }`)
createServer(async (req, res) => {
if (req.method !== 'POST') return res.writeHead(405).end()
const cookie = req.headers.cookie ?? ''
const who = await whoIs(cookie)
if (!who || who.viewer.role === 'GUEST') return res.writeHead(401).end()
let raw = ''
for await (const chunk of req) raw += chunk
const { pathId } = JSON.parse(raw)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(await buildReport(pathId, who.viewer.user)))
}).listen(8080)
TRIP2G_GRAPHQL is trip2g's GraphQL endpoint at its internal address, and buildReport is the API's own work. viewer.role is GUEST, USER or ADMIN. A visitor who isn't signed in, or whose cookie has expired, gets GUEST and user: null; a banned user gets an errors entry, which askTrip2g turns into null. The schema has no user id: user.email is what identifies the person, and it is null for an account signed in without an email.
Stronger: check the resource, not just the person. Before serving data about a note, ask trip2g for that note on the user's behalf. The public note query answers only when the user may open the note on the site; otherwise it returns an errors entry (Need auth, Need subscription, Sign in required, page not found) and no note. Refuse whenever trip2g did:
const canRead = askTrip2g(`query ($pathId: Int64!) {
note(input: { pathId: $pathId, referer: "" }) { pathId }
}`)
const allowed = await canRead(cookie, { pathId })
if (!allowed?.note) return res.writeHead(403).end()
Now the access rules live in one place, the vault's subgraphs and paywalls, and the API can't drift from them. Serve exactly the pathId you checked. A successful check by a signed-in reader counts as a view of the note in their reading history, as opening the page would.
Rules.
- The forwarded cookie is the user's whole session. It works for every trip2g call that user could make, writes included for an admin. Send it only to trip2g; never log it, store it or pass it to another service. Signing out only deletes the cookie from the browser: a copy someone kept stays valid until it expires, 30 days by default.
- Refuse
GETon anything that changes state.SameSite=Laxkeeps the cookie off a cross-sitePOST, but the browser still sends it with a top-levelGETnavigation from another site. An endpoint that only reads may answerGET. - Every API call costs a trip2g call. You can cache trip2g's answer for a few seconds, keyed by the cookie value. Then a ban or a revoked subscription reaches the API only when the cached answer expires.
- Call trip2g at its internal address, not the public domain. The cookie then doesn't cross the internet a second time, and the call doesn't go back through the proxy.
trip2g asks nothing more of a call from another process: no Origin, no Referer, no CSRF token or custom header. It reads the cookie from the Cookie header of any POST with Content-Type: application/json, the same as from a browser. The Secure flag only tells browsers not to send the cookie over plain HTTP; the server accepts it either way, so a plain-HTTP call on the internal network works.
Accept data without a backend: forms
An app that collects something, such as an order, a sign-up or feedback, needs no server of its own to keep it. A trip2g form already stores the submissions, decides who may send one and keeps spam out. Declare the form in the note's frontmatter as Forms in notes describes, and the app sends it with the submitForm mutation:
- Fields from the definition. The layout prints
note.FormSpecJSON()into a<script type="application/json" id="form-spec">, and the app builds its inputs from it: names, types,required, limits. It also carriesnote_version_id, which the submit sends asnoteVersionId. See Forms → Custom layout. - The submit goes to
/_system/graphqllike every other call on this page. The fields and every result are in Forms → Submitting via GraphQL. - Spam protection. Cloudflare Turnstile is on for every form unless it sets
turnstile: false. Without a valid token the result isTurnstileRequiredPayloadwith the site'ssiteKey: the app shows the widget and sends the same input again with the token. - Submissions are listed in the admin panel under Forms, and each one sends the site's admins an email. An admin reads them over GraphQL with
admin { formSubmits … }, an admin call: Forms → Reading submissions.
A working example is the live demo form; its layout is a single file you can read and copy.
const submitForm = makeRequest(`mutation ($input: SubmitFormInput!) {
submitForm(input: $input) {
__typename
... on SubmitFormPayload { submitId }
... on FormSubmitDeniedPayload { reason }
... on TurnstileRequiredPayload { siteKey }
... on ErrorPayload { message }
}
}`)
const spec = JSON.parse(document.getElementById('form-spec').textContent)
async function send(fields, turnstileToken) {
const input = { noteVersionId: spec.note_version_id, formId: '', fields, turnstileToken }
const { submitForm: result } = await submitForm({ input })
if (result.__typename === 'TurnstileRequiredPayload') {
turnstile.render('#captcha', {
sitekey: result.siteKey,
callback: (token) => send(fields, token),
})
}
return result
}
await send([{ name: 'email', stringValue: 'alice@example.com' }])
turnstile comes from Cloudflare's script, <script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>, in the layout, and #captcha is an empty element for the widget. formId is "" for a single form:, or the key of one form under forms:.
Who may submit. The server checks two things, in this order:
- May the visitor read the note? A note the visitor can't open answers
ErrorPayloadwithform_not_found, as if it had no form. can_submit. Omitted orguest: anyone who passed the first check, signed in or not.admin: only a signed-in admin; anyone else getsFormSubmitDeniedPayloadwithreason: "admin_required".paid_userisn't enforced yet and refuses everyone, the admin included, withreason: "not_implemented". A value trip2g doesn't know, such asuser, counts asguestand opens the form to everyone.
What that means for members. No can_submit value means "signed-in members only". The note's own access is what narrows a form to them: put the form on a note without free: true in a subgraph marked Require sign-in, and any signed-in user may submit while a guest gets form_not_found; in a paid subgraph, only those with access may. A form on a note open to everyone takes submissions from everyone. So a shop with member accounts can't make a form members-only with can_submit yet; it has to rely on the note's access, see Subgraphs. When the submitter is signed in, the submission keeps their account, readable as user in formSubmits.
For example: a small shop
How the pieces on this page could fit together for a shop. This is an illustration, not a shop feature: trip2g has no catalogue, cart or checkout of its own.
- Products are notes, one per product, with the price and stock in the frontmatter:
price: 1200,stock: 5. - The storefront is a layout that lists them with
nvs.ByGlob("shop/*.md").Public()and reads each one'sM().GetFloat("price", 0); see Jet functions. - The cart lives in the browser, in
localStorage, or in an API of your own at/_system/extra/cartthat asks trip2g'sviewerwho the shopper is, as in Your own API. - The order is a trip2g form, not your API: a checkout note declares a
form:with the buyer's contacts and a text field for the cart, and the app submits it as in Forms. It arrives in the admin panel and the admins' mail.
Nothing here changes the stock or checks the price: the order holds whatever the browser sent, and the stock goes down when someone edits the product note.
Where to find more
The operations above are tested against the schema. For anything else:
-
The schema. internal/graph/schema.graphqls lists every query, mutation and subscription with its input and result types. Signed in as admin, open
/_system/graphqlin a browser to explore it in GraphiQL. -
The Obsidian sync client. Its operations file, src/operations.graphql in github.com/trip2g/obsidian-sync, holds the API-key operations it runs: reading notes and their assets,
pushNotes,hideNotes,uploadNoteAsset,commitNotes. The trip2g repository carries the client as theobsidian-syncsubmodule; GitHub doesn't show a submodule's files under trip2g, so here is the same file at the commit trip2g pins. -
The admin panel. Every screen keeps its operations in
.graphqlfiles beside its code, under assets/ui/:admin/for the admin panel,editor/for the note editor,user/for the reader's side. Find the one you need by the field it calls, with GitHub code search over those files (add the field name to the query) or in a clone:grep -rl --include='*.graphql' 'noteVersionHistory' assets/ui
Build your own
- Decide the markdown format first. The note must stay readable and editable without your app: a list, a table, headings. Kanban reuses the obsidian-kanban plugin's format, so the same note is a board in Obsidian too.
- Write a parser and a serializer that round-trip the format exactly, and test that
serialize(parse(text)) === text. Anything the app doesn't model must survive a save unchanged. - Write the layout above, with your own mount element and bundle.
- Read the data from the page, render it, and save with
updateNotesandexpectedHash. - Show edit controls only when
editableis true. The server enforces the rule anyway.
Checklist
- The note has
layout: app, and the layout printsdefaultTemplate.Styles()anddefaultTemplate.UserSpaceScripts()in<head>,Header()before the app andFooter()after it. - The page has a header note, or the layout has its own
$trip2g_user_spacemount element: otherwise there is no sign-in button. - The app calls
/_system/graphqlby a relative URL, asPOSTwithContent-Type: application/json, with default credentials orcredentials: 'include'. - No API key or token in the page, no login screen of the app's own.
- Edit controls show only when the layout's
currentUser.IsAdmin()says so; a visitor sees the note read-only and the header's sign-in button. - Every save passes
expectedHashand handlesUpdateNotesHashMismatchPayloadand anerrorsentry. - Every note the app reads as data has a
validatevalidator, and the app showsupdated[].warningsafter a save. - MCP calls go to
/_system/mcpby a relative URL, asPOSTwithContent-Type: application/jsonand the browser's defaultAccept, and handle both a JSON-RPCerrorand a result withisError. - An API of your own sits under
/_system/extra/on the site's host, not on a subdomain,/api/or another/_system/path; it sends theCookieheader only to trip2g at its internal address, never logs or stores it, and refusesGETon endpoints that change anything. - Data the app collects goes through a trip2g form: the app handles
TurnstileRequiredPayload,FormSubmitDeniedPayloadandErrorPayload, and a form meant for members sits on a note only they can read.
See also
- Kanban board template and its source, github.com/trip2g/kanban_template
- updateNotes — the write API in full
- GraphQL API
- MCP Server — the tools
/_system/mcpoffers - Forms in notes — form fields,
can_submit,turnstileand reading submissions - Subgraphs — who may read a note, and so who may submit its form
- MCP Graph Walk and its layout, docs/_layouts/search_visualizer.html
- json() and writeJson()
- Templates