The window.gt Runtime
This is the second page of the guide to building General Text apps; read the overview first for the model, the contract, and the quickstarts. Here is the reference for window.gt, the runtime the platform injects into every app: the file API, files the user grants you, theming, versioning, and identity. For collaborative editing that has to merge by structure (rich text, tables, outlines) and for live cursors and presence, see Real-time Collaboration.
The platform owns the sync client and injects it into every app as window.gt. You never copy a client file or bundle Yjs. window.gt is available synchronously; gate on the connection with await gt.ready.
It's tiered, simplest first, so the simplest apps never touch a CRDT, and richer apps drop down only as far as they need: plain-string files → a live Y.Text → a full structured Y.Doc → presence/cursors.
The file API
High-level, plain strings (the default). Whole-file reads/writes and a change subscription. No Yjs knowledge required.
await gt.ready
const text = await gt.readFile('items.jsonl') // → string
await gt.writeFile('items.jsonl', text + '\n{"done":false}') // see note below
await gt.deleteFile('old.md')
const files = await gt.listFiles() // → [{ path, sizeBytes }]
const paths = gt.files() // → string[] (current, synchronous)
// Subscribe to a file's content: cb fires now and on every change (local + remote).
const stop = gt.watch('items.jsonl', (content) => render(content))
// stop() to unsubscribe
// Observe the file list (fires now and on add/remove):
gt.watchFiles((paths) => renderSidebar(paths))
Paths are relative: never hardcode your install folder. Every path you pass to window.gt is relative to your own data folder, and the lists you get back (gt.files(), gt.watchFiles(), gt.listFiles()) are relative too. So write gt.writeFile('v0/items.jsonl', …) and match on 'v0/…', not '_gtApps/myapp/data/v0/…'. Your app does not know (and must not assume) its install-folder name: the same app is installed under its gallery id (alice.myapp), a preview slot, or myapp in standalone dev, and the runtime maps your relative paths into whichever it is. Hardcoding _gtApps/{name}/… works when you write it (the runtime tolerates and remaps it) but silently breaks the moment you list and filter files: your own files come back under a different folder and your filter misses them. Stay relative and you never hit this. (If you're an LLM generating an app: use relative paths exclusively.)
Writes are granular, not wholesale. gt.writeFile(path, content) does not clobber the file; it computes the minimal change between the current content and yours and applies just that as a CRDT edit. So a "whole-file write" still merges cleanly with a collaborator editing the same file at the same time, and only the bytes that actually changed move. For the best concurrent behavior, write the whole new content you want (let the runtime find the diff) rather than hand-patching offsets, and prefer frequent small writes over occasional giant ones. For character-level cursors in a live editor, drop to the CRDT escape hatch below.
Live CRDT, the escape hatch. For a real text editor with character-level realtime collaboration, get the live Y.Text and bind it to a CRDT-aware editor (e.g. y-codemirror.next). Even here you don't bundle Yjs; the methods ride on the object the runtime hands you.
const ytext = gt.subscribeFile('notes/today.md') // → Y.Text
ytext.observe(() => render(ytext.toString()))
gt.applyDiff(ytext, ytext.toString(), newValue) // minimal-diff whole-string write
// gt.unsubscribeFile('notes/today.md') when done
For structured content that has to merge by structure rather than characters (a ProseMirror document, a table, an outline), and for live cursors and presence, drop one level further, to the Y.Doc and awareness APIs documented in Real-time Collaboration. Most apps never need to; the string API above is the right tool whenever your docs are effectively single-user.
Runtime info & versioning
window.gt's surface is a public, versioned contract: additive within a major, never removed.
gt.version // e.g. '1.0.0' (the runtime API contract version)
gt.atLeast('1.0') // true if the running runtime satisfies this minimum
gt.require('1.0') // throw a clear error now if the host is too old (call at startup)
if (gt.someNewThing) {
/* feature-detect new surface */
}
The runtime also logs [gt] runtime vX.Y.Z to the app frame's console on load.
You can declare a minimum the platform records (and warns on) in your manifest: "gtApi": "^1.0".
Identity & connection
Most apps need neither; if your frame loaded, the user is in a workspace.
const user = await gt.user() // → { id, name, image? } | null (no email in-app)
gt.workspaceId // the connected workspace id
gt.connected // boolean — current connection state (sync, for status UI)
gt.fileMeta('items.jsonl') // → { sizeBytes, version } | undefined
gt.on('connected', () => ...)
gt.on('disconnected', () => ...)
gt.on('mode-changed', (mode) => ...) // 'realtime' | 'offline' (desktop offline)
gt.on('error', (err) => ...) // a sync/runtime error surfaced to the app
gt.on('file-changed-externally', (path) => ...) // real out-of-band edit; see Real-time Collaboration
gt.user() gives you the signed-in user's id, name, and (if set) image. Use it to label things ("created by", a leaderboard) and to publish a real name on presence/cursors (subscribeFileAwareness). It deliberately omits email: apps are untrusted, so the sensitive field never crosses into the sandbox (name/image are already visible to collaborators). It can be null (older shells, demo sessions, signed-out), so feature-detect and fall back (e.g. keep a manual name field) rather than assuming it's set. And treat it as app-asserted for display, not a verified identity: it's right for labels, not a basis for a trust/security decision.
Beyond your data folder: granted files
Your default scope is your own data/ and nothing else. To work with the user's own files (the .md notes at the workspace root, a folder of records, an image someone dropped in), the user grants your app access to a folder or a file extension (they approve it from your app's access settings; there's no silent access). Because granted files live outside your data folder, you reach them through a separate, explicit API that takes absolute workspace paths (the exact strings gt.grantedFiles() hands back) and never rebases them. That keeps your everyday gt.readFile/writeFile calls unambiguously about your own data, and cross-folder access something you opt into by name.
Enumerate what you've been granted:
// → [{ path: 'notes/today.md', sizeBytes: 812, kind: 'file', mode: 'readwrite' },
// { path: 'inbox/scan.png', sizeBytes: 43110, kind: 'blob', mode: 'read' }]
const files = gt.grantedFiles()
// Fires now and whenever the granted set changes:
const stop = gt.watchGranted((files) => renderGrantedList(files))
Each entry tells you how to treat the file. kind: 'file' is text (the text methods below), 'blob' is binary (readBlob/writeBlob). mode: 'readwrite' means you may edit it; 'read' means read-only: open it in a read-only view, because structural edits (typing into an editor bound via subscribeFileDoc) to a read-only-granted file are silently dropped by the scope gate (they don't persist and you get no error). Check mode before wiring up editing.
Open them with gt.granted.*, the same shapes as the top-level methods, but every path is an absolute workspace path from grantedFiles():
// Text — reads/writes/subscribes just like gt.readFile & friends:
const md = await gt.granted.readFile('notes/today.md')
await gt.granted.writeFile('notes/today.md', md + '\n- one more thing')
const stop = gt.granted.watch('notes/today.md', (content) => render(content))
const ytext = gt.granted.subscribeFile('notes/today.md') // live Y.Text, for an editor
await gt.granted.deleteFile('notes/scratch.md')
// Full editor parity — the CRDT escape hatches work on granted files too:
const fileDoc = gt.granted.subscribeFileDoc('notes/today.md') // structural merge (y-prosemirror, …)
const aware = gt.granted.subscribeFileAwareness('notes/today.md') // live cursors/presence
await gt.granted.whenFileSynced('notes/today.md') // WAIT before seeding (don't clobber their file)
gt.granted.unsubscribeFile('notes/today.md') // release on close/switch
// Binary — bytes in, bytes out:
const bytes = await gt.granted.readBlob('inbox/scan.png') // → Uint8Array
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }))
await gt.granted.writeBlob('inbox/edited.png', newBytes) // Uint8Array | ArrayBuffer | Blob
Every gt.granted.* call is scope-checked: a path the user hasn't granted, or a write against a read-only grant, throws. So drive your UI off grantedFiles() rather than guessing paths. A read-only grant permits the reads (readFile/readBlob/subscribeFile/subscribeFileDoc/subscribeFileAwareness/whenFileSynced/unsubscribeFile/watch); a read-write grant additionally permits writeFile/writeBlob/deleteFile.
A few things to know:
- Full editor parity.
subscribeFileDoc+subscribeFileAwareness+whenFileSynced+unsubscribeFileare all here, so a granted file opens in your real editor, with structural CRDT merge (y-prosemirror etc.) and live cursors, exactly like a file in your owndata/. See Real-time Collaboration for the contract (contentstays canonical; rebuild onfile-changed-externally). One extra caution for granted files: alwaysawait gt.granted.whenFileSynced(path)before you seed/project structure. A granted file is the user's real content, so seeding an empty structure before their.mdhas synced would overwrite it (the "empty doc wipes the file" pitfall, on their file, not your scratch data). - Text vs binary. Binary files sync as content-addressed blobs, not through the live CRDT, so
subscribeFile/subscribeFileDoc/watch/readFileare text-only; usereadBlob/writeBlobfor anything binary (checkkind). - Encryption is handled for you. In an encrypted workspace the platform decrypts on read and encrypts on write on your behalf; your app only ever sees plaintext bytes and never touches a key. (Because the shell does that crypto,
readBlob/writeBlobwork only inside General Text. In standalone dev, outside the shell, they throw.) - Grants are user-driven. Your app can't request access programmatically; the user approves a folder or extension for it in the app's access settings. So surface what you'd do with access, and let
grantedFiles()being non-empty gate the feature. - This is only for granted files. Your own
data/stays on the ordinary relative-path methods (gt.readFile('v0/x.jsonl'), etc.);gt.granted.*is exclusively for paths outside it that the user approved.
Light & dark: defer to the shell
The shell owns the theme. Your app should follow it, not decide for itself. General Text has a light/dark mode (and color themes) the user controls, and the shell can be dark while your app opens, and if your app hardcodes a light look, it pops up as a jarring bright rectangle inside a dark workspace. So the rule is simple: inherit the shell's theme; never hardcode a scheme, and never key off prefers-color-scheme (that's the OS setting, which can disagree with the shell, so follow the shell instead).
You get this almost for free. The runtime applies the shell's theme to your app automatically the moment it loads, and again whenever the user switches it:
- sets
color-schemeon<html>(so native controls, scrollbars, and form widgets flip), - toggles a
darkclass on<html>(style withhtml.dark .thing { … }or Tailwind'sdark:), - injects the platform design tokens as CSS custom properties on
:root, so you can paint with the exact same palette as the shell.
So the lowest-effort, best-looking path is to build with the tokens and let the shell drive everything:
body {
background: var(--gt-bg);
color: var(--gt-fg);
}
.button {
background: var(--gt-accent);
color: var(--gt-accent-fg);
}
.card {
background: var(--gt-bg-elev);
border: 1px solid var(--gt-border);
}
Useful tokens (all flip automatically with the mode): --gt-bg (app background), --gt-bg-sub, --gt-bg-elev (raised surfaces), --gt-fg / --gt-fg-2 / --gt-fg-3 / --gt-fg-4 (text, decreasing emphasis), --gt-border, --gt-border-strong, --gt-divider, --gt-accent, --gt-accent-soft, --gt-accent-fg. Prefer these over hardcoded colors so your app stays coherent across every theme, but you don't have to: if you keep your own palette, at least branch on the dark class / color-scheme so dark mode isn't a white flashbang.
If you need the values in JS (e.g. to color a <canvas>), read and react:
gt.theme // → { mode: 'light' | 'dark', vars: { '--gt-bg': '#…', … } }
gt.on('theme-changed', (t) => repaintCanvas(t.mode)) // fires on every shell toggle
There's no light flash on open: the runtime applies the mode synchronously at first paint (before your content renders), then refines with the full palette over the handshake.
Testing / demo mode. Outside the shell (your own pnpm dev, or a self-hosted demo) there's no shell to inherit from, so the runtime leaves the theme alone and your app uses its own default. A manual light/dark toggle is fine for local testing or a gallery demo, but it should never override the shell in a real install. Gate it on demo/standalone (gt.sync.isLocal is true in a local workspace or a demo; gt.mode === 'demo' for the gallery session specifically) and otherwise defer to gt.theme / the dark class the shell drives.