Building Apps
Building apps for General Text
General Text is a place for plaintext files. People keep workspaces of plain files that sync live across their devices and collaborators. Apps are small, single-purpose web frontends that read and write those files — an editor for a format, a viewer, a tool. This document is everything you need to build one.
If you are an AI agent: you can build a complete, working app from this file alone. Read it fully, then scaffold.
The model in one paragraph
An app is a static web frontend (HTML + JS + CSS — any framework or none). It has no backend: it never runs server code, never holds a database, never stores anyone's data. General Text is the backend — it provides auth, storage, and real-time sync. Your app runs inside General Text in a sandboxed iframe and talks to files through one global, window.gt, which the platform injects for you. You don't bundle a client, copy any file, or wire up auth — window.gt is just there. You build static files, install them by URL (or paste/drop them), and the user's data lives in their workspace as plain files they own.
What this buys you: no auth to build, no database to run, no server bill, no custody of anyone's data, and your app's data is portable plaintext the user (and their other tools, including their AI) can read forever.
The contract
- You define a plaintext file format (or reuse one:
.md,.csv,.json/.jsonl, or your own.myapp). Files are the contract — anything your app writes, the user can open with anything else. - You build a static frontend that reads/writes those files via
window.gt. - You declare a manifest — a
gt.jsonat the root for a multi-file build, or an inline<script type="application/gt+json">for a single file. - That's it. General Text injects the runtime, syncs the files, runs the auth, hosts the app, and loads it for the user.
Rules that never change:
- No backend. If you find yourself wanting a server endpoint, a webhook, or a private API — that's not how apps work here. Do it client-side, or it doesn't belong.
- No network egress. Apps run under a CSP that allows talking to the General Text API only. Your app cannot phone home, load third-party scripts, or exfiltrate data. Bundle everything; no CDN
<script>tags. - Plaintext, merge-friendly. Prefer many small files or JSONL (one JSON object per line) over one big JSON blob — concurrent edits merge cleanly at the character level, and a giant JSON file can merge into invalid JSON.
The runtime: window.gt
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 has two tiers so the simplest apps never touch a CRDT:
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}') // whole-file write
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))
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
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.1') // true if the running runtime satisfies this minimum
if (gt.someNewThing) {
/* feature-detect new surface */
}
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, email } | null
gt.workspaceId // the connected workspace id
gt.on('connected', () => ...)
gt.on('disconnected', () => ...)
gt.on('mode-changed', (mode) => ...) // 'realtime' | 'offline' (desktop offline)
Quickstart (single file)
The simplest app is one self-contained index.html. The manifest is inline so it stays literally one file. No build, no dependencies, no client to copy:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Scratch</title>
<script type="application/gt+json">
{ "name": "scratch", "displayName": "Scratch", "version": "1.0.0" }
</script>
</head>
<body>
<textarea id="ed" style="width: 100%; height: 100vh"></textarea>
<script type="module">
await window.gt.ready
const path = '_gtApps/scratch/data/v1/note.md'
const ed = document.getElementById('ed')
const ytext = gt.subscribeFile(path)
let applying = false
const render = () => {
if (!applying && ed.value !== ytext.toString()) ed.value = ytext.toString()
}
ytext.observe(render)
render()
ed.addEventListener('input', () => {
applying = true
gt.applyDiff(ytext, ytext.toString(), ed.value)
applying = false
})
</script>
</body>
</html>
That is a complete, working General Text app: it syncs a note live across every device and collaborator, persists as a plain .md file the user owns, and needed no backend, no auth code, no database, and nothing bundled.
Quickstart (multi-file SPA)
Single-file and multi-file are the same model on a spectrum — same runtime, same sandbox, same window.gt. The only difference is file count and whether there's a build. A multi-file app ships a gt.json at the build root and references its assets under /assets/:
my-app/
index.html # entry point; references /assets/*
src/main.ts # your app, using window.gt (no client to import)
gt.json # manifest
vite.config.ts # or any bundler that outputs under /assets/
gt.json (served at the site root):
{
"name": "tally",
"displayName": "Tally",
"version": "1.0.0",
"gtApi": "^1.0",
"extensions": ["tally"]
}
name: lowercase letters, digits, hyphens. Unique; it's your app's id and its storage folder (you write under_gtApps/tally/data/).displayName: shown to users.version: semver. Bump the major only when you break your file format (rare). See "Where your data goes".gtApi(optional): the minimumwindow.gtcontract your app needs.extensions(optional): file extensions your app edits, without the dot. Omit for apps that manage their own data and don't open user files.
Build to a static directory. Asset references in index.html must point under /assets/ (Vite's default) — General Text fetches index.html and its /assets/* files when installing.
TypeScript note.
window.gtis injected at runtime, so add a type for it:declare global { interface Window { gt: any } }(or a precise type matching this doc).
Where your data goes
Your app lives under _gtApps/{name}/, which has three parts:
_gtApps/{name}/
installed.json install record — the platform owns this (you can't touch it)
code/{semver}/ your built code, immutable — the platform owns this too
data/ your files — the ONLY thing your app can read and write
- Your data folder is
_gtApps/{name}/data/. That's your sandbox: the app's default permission is read-write todata/and nothing else. You can't read or rewrite your own install record or code — that's enforced, not a guideline. - Version your data inside
data/: write under_gtApps/{name}/data/v{major}/…, where{major}is yourgt.jsonversion's major (so0.x→data/v0/). You almost always stay on one version. Only bump the major on a breaking format change — and then your app migrates its own data: on first run as the new major, readdata/v{old}/, transform, and writedata/v{new}/. The platform does not migrate for you. - User files at the workspace root (
notes/,*.md) are the user's corpus, outside your default scope. An app that edits them (a Markdown editor) requests a grant — a folder or extension scope the user approves (see Security). Until granted, you only havedata/.
Keep data legible: one record per file, or JSONL. It syncs better, it greps better, and the user's AI can read it.
Developing and testing
You do not need a running General Text to build an app. There are three tiers; live mostly in the first.
Tier 1 — Standalone (pnpm dev, your inner loop). Run your app's own dev server (e.g. Vite on http://localhost:5180) and open it in a normal tab. You need window.gt present in dev — inject it from the public runtime URL with a tiny dev-only Vite plugin (the runtime is platform code, served openly for exactly this):
// vite.config.ts
import { defineConfig, type Plugin } from 'vite'
// Dev-only: inject window.gt so the app runs standalone with a local
// in-browser workspace (IndexedDB + cross-tab sync). In production General
// Text injects the runtime itself, so this never ships.
function gtRuntime(): Plugin {
return {
name: 'gt-runtime',
apply: 'serve',
transformIndexHtml: (html) =>
html.replace(
'</head>',
'<script src="https://www.generaltext.org/__gt/runtime.js"></script></head>',
),
}
}
export default defineConfig({ plugins: [gtRuntime()] })
With window.gt present on localhost and no host on the URL, the runtime automatically runs a local in-browser workspace: every file is a real Yjs doc persisted in IndexedDB, with cross-tab realtime over BroadcastChannel. Your code is byte-for-byte what ships — only the transport changes. Open two tabs and watch edits merge; that's the real sync model, no server. (A small dev panel, bottom-right, lets you reset or mirror the workspace to a real folder on disk.)
Tier 2 — Build check. vite build and vite preview to confirm assets resolve under /assets/ and gt.json is at the site root.
Tier 3 — Real General Text (before you ship). This is the integration test: it exercises the iframe sandbox, the CSP, install/content-addressing, auth, and true multi-user CRDT against the server.
- Serve your built app at a URL (e.g.
vite preview),gt.jsonat the root. - Run General Text locally (
pnpm dev) or use your deployment. - In General Text: open a workspace → Settings → Apps → Install by URL → paste your app's URL. General Text fetches
gt.json+index.html+/assets/*, content-addresses them, and installs the app. - Open a file your app handles (or launch the app) — it loads in a sandboxed iframe with the runtime injected, so
window.gtconnects to the real host instead of the local workspace.
Note: installing from an arbitrary URL is open in local dev but restricted in production (the source origin must be allowlisted) until app code is fully sandboxed.
Constraints checklist (read before you ship)
- Static build only. No server, no SSR, no API routes of your own.
- All assets bundled and referenced under
/assets/. No third-party<script>/<link>to CDNs (the CSP blocks them anyway). The one exception is the dev-only runtime injection above, which never ships. - All persistence goes through
window.gt(files). NolocalStoragefor anything that should survive or sync; no IndexedDB as a source of truth. (A disposable in-browser index rebuilt from files is fine.) - Data is plaintext and merge-friendly (per-file or JSONL).
- A manifest is present —
gt.jsonat the site root (multi-file) or an inline<script type="application/gt+json">(single-file) — with a uniquename. - Works inside an iframe (no top-level navigation assumptions; read context from
window.gt, not from cookies or your own origin).
Legacy: the bundled gt-sync client
Before window.gt, apps copied a gt-sync.ts client into their repo and bundled yjs/y-protocols/lib0. That still works — window.gt is a thin wrapper over the same client — but it's no longer necessary or recommended. New apps should use the injected window.gt and bundle nothing. (window.gt.sync exposes the underlying client instance if you ever need an unwrapped method.)