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, reading the workspace’s Shared Files, 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, kind: 'file' | 'blob' }]
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. Large writes are fine (the runtime chunks them across sync frames automatically) but they cost proportionally more to store (see Writing efficiently). For character-level cursors in a live editor, drop to the CRDT escape hatch below.
Binary files: readBlob / writeBlob (runtime ≥ 1.2). Images the user picks, a PDF you generate, a thumbnail you cache: anything that isn't text goes in and out of your data folder as bytes, on the same relative paths:
await gt.writeBlob('photos/loaf.jpg', file) // Uint8Array | ArrayBuffer | Blob (a File is a Blob)
const bytes = await gt.readBlob('photos/loaf.jpg') // → Uint8Array
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/jpeg' }))
await gt.deleteFile('photos/loaf.jpg') // deleting is the same call as for text
Blobs are stored content-addressed rather than in the text CRDT, sync to every device and member like any other file, and are encrypted on the same terms (the shell holds the key; your app only ever sees plaintext bytes). They show up in gt.listFiles() with kind: 'blob', and because they don't live in the CRDT, readFile/watch/subscribeFile read empty for them. Check kind and branch.
Two things to weigh before you reach for one:
- Text first, and it's not close. A blob is opaque: it can't merge, so concurrent writes are last-write-wins on the whole file, and neither a human nor the user's agent can read or edit it. Records, notes, settings, anything you'd want legible in the file editor: write those as text, even when a binary format would be smaller. Blobs are for content that is genuinely binary.
- Size. A blob caps at 100 MB (against a text file's 16 MB of synced state), but every byte syncs to every device the workspace is on. Store the photo the user gave you, not the 30 MB original when a 300 KB copy renders the same.
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.
Writing efficiently (and what it costs)
Every change you sync is stored as one frame. For ordinary edits it's the number of frames, not their byte size, that drives sync cost: a frame under ~512 KB costs the same to store whether it carries one character or a whole batch of edits, so the goal is fewer, fatter frames. (Truly large frames cost proportionally to size, one stored unit per 512 KB, so a giant write is never cheaper split into many files; it just is what it is.) Two rules cover almost everything:
1. Batch related changes into one write. gt.writeFile diffs against the current content, so one writeFile of the final content is one frame for any normal-sized change (very large changes chunk into a few), while ten writeFile calls in a loop are ~ten frames. Compose the whole new content, then write once:
// costly: one frame per iteration
for (const item of items) {
const cur = await gt.readFile('items.jsonl')
await gt.writeFile('items.jsonl', cur + line(item))
}
// cheap: compose once, write once
const cur = await gt.readFile('items.jsonl')
await gt.writeFile('items.jsonl', cur + items.map(line).join(''))
On the CRDT escape hatch, wrap several mutations in a transaction so they collapse into one update (one frame):
const doc = gt.subscribeFileDoc('board.json')
const rows = doc.getArray('rows')
doc.transact(() => {
rows.push([newRow]) // several mutations…
rows.delete(0, 1) // …one frame, not two
})
The shell also coalesces rapid bursts for you (successive edits within ~250 ms merge into one frame), so a live editor bound to Y.Text won't emit a frame per keystroke. You still batch programmatic bulk writes yourself; a tight loop outruns that window.
One exception to the transaction rule: never wrap gt.applyDiff (or gt.writeFile) in your own doc.transact(). They manage their own transactions so a large write can split into safely-sized updates; an outer transaction re-merges everything into one giant update and defeats that.
2. Put transient state on the ephemeral channel, not in a file. Cursors, selections, "who's here", drag positions, a value ticking many times a second, anything you don't need to persist, belongs on awareness (gt.subscribeFileAwareness(path)), which is relayed to peers in real time and never stored. Writing that churn into a file turns every tick into a stored frame.
One trap: the structure channel on gt.subscribeFileDoc (Y types other than the file's text) is not ephemeral. It isn't written into the plaintext file, but it is still synced and stored as frames. Only awareness is free.
Size limits: a text file's whole synced state can be up to 16 MB; large writes and pastes chunk across the wire automatically, so you don't manage this. Past that ceiling a change is rejected (the user sees a "too large to sync" notice), so genuinely huge data belongs in multiple files or in a binary blob (100 MB cap), not one enormous text file.
Runtime info & versioning
window.gt's surface is a public, versioned contract: additive within a major, never removed.
gt.version // e.g. '1.3.0' (the runtime API contract version)
gt.atLeast('1.3') // true if the running runtime satisfies this minimum
gt.require('1.3') // throw a clear error now if the host is too old (call at startup)
if (gt.someNewThing) {
/* feature-detect new surface */
}
What arrived when, so you know what to gate on: 1.1 added gt.openExternal; 1.2 added gt.readBlob/gt.writeBlob for your own data folder and kind on gt.listFiles() entries; 1.3 added gt.shell (the fleuron's position, a nudge, and opening the workspace sheet) and the --gt-fleuron-* CSS variables.
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, kind? } | 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.openExternal('https://example.com') // new tab on web, system browser on desktop (≥ 1.1)
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.
Location: deep links, refresh, and back
The shell owns the page URL, so by default your app's internal navigation is invisible to it: a refresh reopens your default view, and nothing inside your app can be linked or bookmarked. The location bridge fixes that, opt-in:
// Tell the shell where you are. Replace by default; push on a real navigation.
gt.setLocation('/e/deal_01J8?peek=org_01J9', { push: true })
// Deep links, refresh restores, and browser back/forward arrive here. Fires
// immediately with the boot location if the user opened a link into your app.
gt.onLocation((path) => router.navigate(path))
The shell mirrors your location into your route's URL fragment (…/app/yourapp#/e/deal_01J8), which makes your views real destinations: refresh keeps the user's place, any view can be shared ("here's the Cambridge deal"), and back/forward step through the locations you pushed. Rules of thumb: paths must start with / and stay under 2 KB; call setLocation freely (replaces coalesce, so a filter box announcing per keystroke is fine) but pass { push: true } only on real navigations, so back doesn't crawl through keystrokes. Apps that never call it behave exactly as before.
Subscribe once, and give / somewhere to land. onLocation fires immediately with the boot location for late subscribers, so a subscription set up after the shell has already delivered doesn't miss the deep link. Two consequences worth designing for:
- Keep the subscription stable. If you resubscribe on every navigation, each new subscription is handed a location again. In React that means
useEffect(() => gt.onLocation(...), [])withnavigatereached through a ref:useNavigate()is not referentially stable, so[navigate]resubscribes on every navigation. (The runtime stops replaying once your app announces a location of its own, so this can no longer spin forever, but a stable subscription is still the thing to write.) - Make
/a real view. The shell delivers/when it means "this app's home", which is what reopening an already-open app does. Render your home view at/rather than redirecting to/home; an app that answers a delivered location by navigating somewhere else is an app the shell has to chase.
The shell owns history once you use the bridge. Your router still drives your app; it just stops minting browser history entries of its own. The runtime downgrades your frame's history.pushState to a replace, because a pushState inside an app frame creates a session-history entry in addition to the one the shell creates for the location you announced: two entries per navigation, of which only one is visible, so back (and iOS swipe-back) would appear to do nothing every other press. Practical upshot: any router works (HashRouter, BrowserRouter, your own), history.back() inside your frame correctly steps the shell's history, and you shouldn't rely on history.length or on walking entries inside the frame. If you show a view the user should be able to back out of (a modal, a detail pane) announce it with { push: true } rather than pushing an entry yourself.
No native dialogs: the runtime throws
window.confirm(), prompt(), and alert() do not work inside the app sandbox (no allow-modals), and a silent confirm() → false once shipped a delete button that never fired. The runtime therefore throws on all three with a clear message instead of letting them no-op. Build in-app dialogs; in standalone dev on your own origin the native ones still work.
External links: write plain anchors
Write ordinary links and they work:
<a href="https://example.com">Example</a>
<a href="https://example.com" target="_blank" rel="noreferrer noopener">Example</a>
<a href="mailto:ada@example.com">Email Ada</a>
The runtime intercepts clicks on any link pointing off your origin and hands the URL to the shell, which opens it in a new browser tab on web and in the user's system browser on desktop. You don't need target="_blank" (though it's harmless), and a link can never navigate your app's frame out from under it.
When there's no anchor to click (a context-menu item, a keyboard shortcut, an "open in browser" button), call the API directly:
gt.openExternal('https://example.com') // needs runtime ≥ 1.1
Details worth knowing:
http,https, andmailtoonly. Anything else (javascript:,data:,file:, custom schemes) is refused with a console warning. On desktop the URL reaches the OS opener, so the allowlist is a security boundary, not a formality.window.open()to an external URL is routed the same way, so existing code keeps working. It returnsnullrather than aWindowProxy(the tab belongs to the shell, and is cross-origin regardless), so don't expect to script the opened window. If you need the return value for control flow, you wantedgt.openExternalanyway.- Your own click handlers win. If you call
preventDefault()(a router's<Link>, a custom menu), the runtime leaves the click alone. - Modified clicks stay native. Cmd/ctrl-click, shift-click, and alt-click are handled by the browser as always.
- Downloads are not links.
<a download>with a blob URL saves a file and is left alone: that works in the sandbox and has nothing to do with this path.
Caches: validate shape, not just version
If you persist a derived cache (an IndexedDB projection, a localStorage index), don't trust its version number alone: a build that briefly existed can write a payload that claims the current version with yesterday's shape, and every reload after that blanks your app. Validate the shape of what you read (the fields you're about to touch exist and have the right types) and treat any surprise as a cache miss: rebuild from the canonical files, which are always the source of truth.
Beyond your data folder: Shared Files
Your writable scope is your own data/ folder and nothing else. On top of that, every app gets read access to the workspace's Shared Files: every path outside _gtApps/ and the platform's _gt/ namespace, which is to say the user's own corpus (notes/*.md, a folder of records, an image someone dropped in).
There is nothing to request and nothing for the user to approve: it is the same for every app in the workspace. People write Shared Files; apps read them. If your app needs to own a file, the user moves it into your app with Move into an app… on the Shared Files page, and it arrives in your data/ folder like anything else you wrote.
Because Shared Files live outside your data folder, you reach them through a separate, explicit API that takes absolute workspace paths (the exact strings the list hands back) and never rebases them. That keeps your everyday gt.readFile/writeFile calls unambiguously about your own data.
Enumerate what you can read:
// → [{ path: 'notes/today.md', sizeBytes: 812, kind: 'file', mode: 'read' },
// { path: 'inbox/scan.png', sizeBytes: 43110, kind: 'blob', mode: 'read' }]
const files = gt.grantedFiles()
// Fires now and whenever the set changes:
const stop = gt.watchGranted((files) => renderFileList(files))
Each entry tells you how to open it. kind: 'file' is text (the text methods below), 'blob' is binary (readBlob). mode is 'read' for every Shared File, so present a read-only view: writes throw, and structural edits bound through subscribeFileDoc are dropped by the scope gate.
Open them with gt.granted.*, the same shapes as the top-level methods, but every path is an absolute workspace path from grantedFiles():
// Text:
const md = await gt.granted.readFile('notes/today.md')
const stop = gt.granted.watch('notes/today.md', (content) => render(content))
const ytext = gt.granted.subscribeFile('notes/today.md') // live Y.Text, read-only view
await gt.granted.whenFileSynced('notes/today.md') // wait for the initial state
gt.granted.unsubscribeFile('notes/today.md') // release on close/switch
// Structural + presence, for a real (read-only) editor view:
const fileDoc = gt.granted.subscribeFileDoc('notes/today.md')
const aware = gt.granted.subscribeFileAwareness('notes/today.md')
// Binary, bytes in:
const bytes = await gt.granted.readBlob('inbox/scan.png') // → Uint8Array
const url = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }))
Every gt.granted.* call is scope-checked: a path outside Shared Files, or any write (writeFile, writeBlob, deleteFile), throws. So drive your UI off grantedFiles() rather than guessing paths.
A few things to know:
- Wait before you read.
await gt.granted.whenFileSynced(path)resolves once a file's initial state has arrived. A live subscription reads empty until then, which is easy to mistake for an empty file. - Text vs binary. Binary files sync as content-addressed blobs, not through the live CRDT, so
readFile/watch/subscribeFile/subscribeFileDocare text-only; usereadBlobfor anything binary (checkkind). - Encryption is handled for you. In a synced workspace the platform decrypts on read on your behalf; your app only ever sees plaintext bytes and never touches a key. In standalone dev, where there is no shell to hold a key, blobs land in the local workspace's browser storage instead, so
readBlob/writeBlobwork inpnpm devtoo, on your own data folder. - This is only for Shared Files. Your own
data/stays on the ordinary relative-path methods (gt.readFile('v0/x.jsonl'),gt.writeBlob('photos/1.jpg', …)). Passing one of your own paths togt.granted.*throws.
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 for a browser-local workspace, which now means a demo or your own pnpm dev; gt.mode === 'demo' for the gallery session specifically) and otherwise defer to gt.theme / the dark class the shell drives.
Safe-area insets: the bottom edge is yours
If your app anchors anything to the bottom of the viewport (a tab bar, a sticky action bar, a compose box), honour env(safe-area-inset-bottom). Embedded or standalone, the rule is the same.
On a phone, an app runs with no shell chrome below it: the frame is full-bleed to the physical bottom edge by design, so that a map, a canvas or a game can use the whole screen. The only thing the shell draws over you is the fleuron, and that floats — it is not laid out, and it reserves nothing. So your bar really is the last thing above the home indicator, exactly as it would be on your own deployed site.
.bottom-bar {
padding-bottom: env(safe-area-inset-bottom);
}
The top edge is the other way round: the shell's own status-bar band and header sit above you, so don't add safe-area-inset-top to anything you pin to the top of your viewport — you would be clearing a bar that has already been cleared.
Changed in Phase 5. This used to say the opposite: that the shell's chrome sat below you on mobile and you should resolve
--safe-bottomto0pxwhen embedded (window.self !== window.top). That was true when the touch shell had a fixed bottom tab bar. It has not had one since Phase 5. If your app carries that branch, delete it — its bottom row is currently sitting under the home indicator.
The fleuron: the shell's one button on a phone
On a phone the shell draws exactly one thing over your app: the fleuron, a round button in the bottom-right corner that opens the workspace sheet (apps, Shared Files, People, the switcher). There is no bar and no reserved strip. Your app owns the whole screen except that corner, and the runtime tells you where the corner is.
It is 44px across, 14px in from the right edge and 16px up from the bottom, plus the safe-area inset. While the sheet is open its mark changes to a house and a second tap leaves for the workspace's own screen — worth knowing because it means the corner is how people exit your app, not just how they glance at the workspace.
Three CSS variables on <html> track it, all 0px when nothing is drawn over you (desktop, a wide web window):
/* keep the last row of a list clear of the button */
.list {
padding-bottom: calc(var(--gt-fleuron-bottom) + var(--gt-fleuron-size));
}
--gt-fleuron-size is the button's diameter; --gt-fleuron-right and --gt-fleuron-bottom are the distances from your viewport's edges to its outer edge. The same numbers are on gt.shell.chrome ({ fleuron: { size, inset: { right, bottom } } | null }), and gt.shell.onChrome(cb) fires immediately and whenever it moves.
If your app has its own bottom bar
Move the fleuron up and out of the way. Don't pad around it, and don't try to seat it inside your bar as an extra cell.
// our tab bar is 56px tall; put the fleuron above it
gt.shell.nudge({ bottom: 56 })
Nudge by your bar's content height, not its drawn height — the number above its safe-area padding. The fleuron adds env(safe-area-inset-bottom) on top of your nudge and your bar adds the same inset under itself, so each clears the home indicator exactly once and the gap between them stays a constant 16px on every device. Pass the same constant to your bar's height and to the nudge and there is nothing to keep in sync by hand.
Up rather than inline, for four reasons. The fleuron is not a tab of your app — it is the way out of it, and sitting it in the row makes it read as a destination inside. The number of cells in your bar is your design; a foreign sixth one breaks the grid. Seating it inline would mean the shell knowing your cell width, label style and active colour, and the shell deliberately knows nothing about your app. And 16px of air between a 44px circle and a row of 44px cells is what stops mis-taps at the boundary.
Bar heights. No height is enforced, but these are the ones the fleuron's geometry is tuned for:
| Bar | Content height | Icon | Label | Use when |
|---|---|---|---|---|
| Compact | 48px | 22px | — | Icons only, 3–4 destinations |
| Standard | 56px | 22px | 11px | The default. Icon over label, 3–5 destinations |
| Roomy | 64px | 24px | 11px | 5 destinations, or a bar that also carries an action |
Treat 48px as a floor: below it a labelled cell stops clearing the 44px touch minimum once the label's own line box is counted. For reference, UIKit's tab bar is 49pt of content and Material 3's navigation bar is 80dp including its padding.
If your app has its own corner button
An app whose bottom-right already holds something — a compose button, a FAB — should move the fleuron left, not up. Both axes compose:
gt.shell.nudge({ bottom: 56 }) // a bar along the bottom
gt.shell.nudge({ right: 64 }) // a FAB in the same corner
gt.shell.nudge({ bottom: 56, right: 64 }) // both
A nudge is an extra distance from the right and bottom edges (never negative, capped at 400px); gt.shell.nudge(null) puts the button back. The nudge belongs to your app's frame and is cleared when it closes. gt.shell.open() opens the sheet, for an app that wants a "workspace" affordance in its own menu.
Runtime 1.3 adds gt.shell; guard with gt.atLeast('1.3') if you support older hosts.