General Text & Computing Company

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, 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.2.0' (the runtime API contract version)
gt.atLeast('1.2') // true if the running runtime satisfies this minimum
gt.require('1.2') // 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.

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 — 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(...), []) with navigate reached 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" — reopening an already-open app does exactly that. 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, and mailto only. 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 returns null rather than a WindowProxy (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 wanted gt.openExternal anyway.
  • 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: 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 + unsubscribeFile are 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 own data/. See Real-time Collaboration for the contract (content stays canonical; rebuild on file-changed-externally). One extra caution for granted files: always await 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 .md has 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/readFile are text-only; use readBlob/writeBlob for anything binary (check kind).
  • 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. In standalone dev, where there's no shell to hold a key, blobs land in the local workspace's browser storage instead — so readBlob/writeBlob work in pnpm dev too, on your own data folder.
  • 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'), gt.writeBlob('photos/1.jpg', …), etc.); gt.granted.* is exclusively for paths outside it that the user approved. Passing one of your own paths to gt.granted.* throws — it isn't a grant.

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-scheme on <html> (so native controls, scrollbars, and form widgets flip),
  • toggles a dark class on <html> (style with html.dark .thing { … } or Tailwind's dark:),
  • 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.

Safe-area insets: the shell owns the edges

Same principle as the theme, one layer out. If your app anchors anything to the bottom of the viewport — a tab bar, a sticky action bar, a compose box — don't consume env(safe-area-inset-bottom) unconditionally.

Inside General Text your app is an iframe with the shell's own chrome below it on mobile, so the home indicator is already the shell's to clear. iOS reports the full inset to the iframe anyway, so an app that honours it reserves a second band of space that nothing needs, and you get a visible dead strip between your bar and the shell's.

Standalone, though — your deployed site opened on a phone, or added to the home screen — your bar really is the last thing above the indicator and does need the padding. So resolve it once, at boot:

// The shell's chrome is below us when embedded, so the inset is already handled.
const embedded = window.self !== window.top
document.documentElement.style.setProperty(
  '--safe-bottom',
  embedded ? '0px' : 'env(safe-area-inset-bottom)',
)
:root {
  --safe-bottom: env(safe-area-inset-bottom);
} /* the standalone default */
.bottom-bar {
  padding-bottom: var(--safe-bottom);
}

Then style against var(--safe-bottom) and never env(…) directly. The same reasoning applies to safe-area-inset-top if you ever pin something to the top; the shell's header is above you there.