# General Text — for LLMs and agents This file is generated from the General Text documentation (https://www.generaltext.org/docs) and is the canonical machine-readable guide. Human-readable docs live at https://www.generaltext.org/docs. 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. These developer docs are everything you need to build one. They're split across four pages: this overview and quickstart, then [The window.gt Runtime](/docs/runtime) (the file API your app talks to), [Real-time Collaboration](/docs/collaboration) (the structured-CRDT path, for when you need it), and [Publishing to the Gallery](/docs/publishing). If you are an LLM agent: you can build a complete, working app from these docs. The full guide is also served as one machine-readable file at [`/llms.txt`](https://www.generaltext.org/llms.txt); read it fully, then scaffold. ### The model 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, providing 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 1. **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. 2. **You build a static frontend** that reads/writes those files via `window.gt`. 3. **You declare a manifest**: a `gt.json` at the root for a multi-file build, or an inline ` ``` 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 with **relative** paths (`./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 # set base: './' so asset refs are relative (see below) ``` **Build with a relative base** (`base: './'` in `vite.config.ts`, or your bundler's equivalent). General Text serves your app from a few different roots (its own sandbox origin, and a path-prefixed URL for the in-gallery live demo), so relative asset refs (`./assets/x.js`) resolve everywhere, while a hard-coded absolute `/assets/x.js` only resolves on the sandbox origin. Relative is the portable default; the first-party apps all use it. `gt.json` (served at the site root): ```json { "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 minimum `window.gt` contract 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. - `blurb` (optional): a one-line description, shown under the name in the gallery. - `icon` (optional): `{ "kind": "lucide", "value": "notebook" }` (a built-in line icon) or `{ "kind": "svg", "value": "" }` for a custom mark. Custom SVGs are recolored to a single tint that matches the app's color and sanitized on publish, so keep them simple (one color, line or solid shapes); scripts, external references, and event handlers are rejected. Defaults to a generated monogram. - `color` (optional): the icon tint, as one of a curated palette: `red`, `orange`, `amber`, `yellow`, `lime`, `green`, `teal`, `cyan`, `blue`, `indigo`, `violet`, `magenta`, `rose`. Pinning it here keeps your app the **same color everywhere** it appears (sidebar, gallery, the App Builder, the loading splash) and travels with it through publish and install. The platform clamps saturation and lightness to its house style (and adapts them for light/dark), so you pick the hue, not a raw RGB. Omit it and the color is derived from your app's id, still consistent, just not your choice. (The App Builder's icon picker has a swatch row for this.) - `tags` (optional): up to 8 short tags for gallery discovery, e.g. `["notes", "markdown"]`. Lowercased and normalized on publish. - `repository` (optional): an `https` URL to your source. Shown on the detail page, and it's the edit path for multi-file apps (whose published bytes are compiled, so you change the source and push a new version). The manifest is the single source of truth for these: editing them and pushing a new version updates the gallery listing. 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.gt` is injected at runtime, so add a type for it: > `declare global { interface Window { gt: any } }` (or a precise type matching these docs). ### Where your data goes Your app lives under `_gtApps/{name}/`, which has three parts: ``` _gtApps/{name}/ installed.json install record (platform-owned; you can't touch it) code/{semver}/ your built code, immutable (platform-owned 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 to `data/` and nothing else. You can't read or rewrite your own install record or code; that's enforced, not a guideline. **But you never write that path yourself.** You address everything _relative to `data/`_ (see [The window.gt Runtime](/docs/runtime)), and the runtime puts it in the right place regardless of your actual install folder. - **Version your data inside `data/`**: address it as `v{major}/…` (e.g. `gt.writeFile('v0/items.jsonl', …)`), where `{major}` is your `gt.json` version's major (so `0.x` → `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, read `v{old}/`, transform, and write `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) needs a **grant**: a folder or extension scope the user approves, after which you reach those files (text _or_ binary) via `gt.grantedFiles()` + `gt.granted.*` (see [The window.gt Runtime](/docs/runtime)). Until granted, you only have `data/`. Keep data legible: one record per file, or JSONL. It syncs better, it greps better, and the user's AI can read it. **Write for agents, not just for your app.** A user can point an AI agent at their workspace (Agent Access, desktop) and have it read and write your app's data files directly, over the same synced, encrypted path your app uses. So your app's data format is a public contract: **document it in your app's README** ([Publishing to the Gallery](/docs/publishing)) — the paths you write, the shape of each file, and any invariants — because that README is what an agent reads before touching your data. A legible format plus a clear README turns your app's data into something both your UI and the user's agent can work with; an opaque blob shuts the agent out. ### 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, so inject it from the public runtime URL with a tiny dev-only Vite plugin (the runtime is platform code, served openly for exactly this): ```ts // 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( '', '', ), } } 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.) If your app uses the structured-sync or presence APIs (so it aliases `yjs` / `y-protocols/awareness` to `/__gt/*`; see [Real-time Collaboration](/docs/collaboration)), also add the dev `server.proxy` for `/__gt` shown there, so those root-relative imports resolve against the platform in `vite dev`. **A "try it live" demo on your _deployed_ site (not localhost).** The auto-local detection above is **localhost-only**. On your app's own public origin the runtime would instead try to reach a real workspace (and fail with `No workspaceId provided`), so a self-hosted demo page must opt in explicitly: set `window.__gtConfig = { local: true }` **before** the runtime script loads, and you get the same local in-browser workspace on any origin. ```html ``` (This differs from the gallery's own **"Try it live"** button, which runs your app _embedded_ in the sandbox and supplies the demo workspace over the host handshake, no flag needed. `__gtConfig` is only for a demo you host yourself, on your own URL. Inside General Text you never set it: the shell injects the runtime and provides the workspace.) If you inject the runtime yourself like this, load your editor **after** the script runs (the `/__gt/*` facade reads `window.gt` at import; see [Real-time Collaboration](/docs/collaboration)). **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. 1. Serve your built app at a URL (e.g. `vite preview`), `gt.json` at the root. 2. Run General Text locally (`pnpm dev`) or use your deployment. 3. 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. 4. Open a file your app handles (or launch the app). It loads in a sandboxed iframe with the runtime injected, so `window.gt` connects to the real host instead of the local workspace. **Your host must allow cross-origin reads (CORS).** On the web, the install fetch runs in the user's _browser_ (client-side install is what keeps the server blind to what you install), so the origin serving your app must send `Access-Control-Allow-Origin: *` on `gt.json` and every asset. Your app code is public anyway (the gallery serves it publicly), and no credentials are involved, so the wildcard is safe. On Cloudflare (Workers Assets or Pages), drop a `_headers` file in `public/`: ``` /* Access-Control-Allow-Origin: * ``` Without it, web installs fail with a CORS error in the console (desktop installs use a native fetch and are unaffected, so don't let that mask a broken web install). **If your build code-splits, make the lazy chunks discoverable.** Install captures your files by scanning `index.html` for `src`/`href` references and fetching those (the crawl is deliberately dumb: HTML only, no parsing of your JS). A normal statically-imported bundle is fully covered. But a **lazy/dynamic chunk** (anything the bundler splits out behind an `import()`, a `React.lazy` route, a deferred heavy dep) is referenced only from _inside_ your JS, never in the HTML, so the installer never sees it, never uploads it, and the sandbox serves a **404** for it at runtime (which surfaces as a cryptic `error loading dynamically imported module` / `NS_ERROR_CORRUPTED_CONTENT`, not a clean "missing file"). Two ways to stay safe: - **Don't split.** For most apps, bundle to one chunk and there's nothing lazy to miss. Code-splitting a small single-purpose app rarely earns its keep. - **Or emit a `` for every dynamic chunk**, so the crawler discovers it via that `href`. `modulepreload` only _fetches_ a module, it never _executes_ it, so it's safe even for a chunk you run only conditionally (e.g. one you defer specifically so its top-level code doesn't run until `window.gt` exists). A tiny Vite plugin emits them for you: ```ts // vite.config.ts — make lazy/dynamic chunks visible to the install crawler. import type { Plugin } from 'vite' function preloadAsyncChunks(): Plugin { return { name: 'gt-preload-async-chunks', apply: 'build', transformIndexHtml: { order: 'post', handler: (_html, ctx) => Object.entries(ctx.bundle ?? {}) .filter(([, c]) => c.type === 'chunk' && c.isDynamicEntry) .map((entry) => ({ tag: 'link', attrs: { rel: 'modulepreload', href: `./${entry[0]}` }, injectTo: 'head', })), }, } } ``` Note: installing from any URL works in both local dev and production. Installed app code runs sandboxed (an isolated origin, a no-egress CSP, and a scoped data broker) so an app from an arbitrary source can't phone home or reach beyond its own `data/`. (A self-hosted deployment can optionally narrow installs to specific origins via `APP_SOURCE_ALLOWLIST`, but the hosted service is open.) ### 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 ` ``` - **Multi-file:** ship a **`gt-readme.md`** (preferred), or `README.md` as a fallback, at your app's **served web root**, i.e. the build output root next to `gt.json`. For Vite that's the `public/` folder: `public/gt-readme.md` (alongside `public/gt.json`). General Text fetches it on install. **Use `gt-readme.md` so it stays distinct from your repository's top-level `README.md`**, since your repo README is developer docs and is never served, so it's never used as the gallery README. The README is optional. Keep it focused: what the app does, how to use it, what files it writes (e.g. one JSONL record per item under `v1/`). Be specific about the **data format**, because the README is also the contract an **AI agent** reads. A user can point an agent at their workspace ([Agent Access](/docs/agents)) and have it read and write your app's data directly. The README travels with the installed app, so it's what the agent consults first: spell out the paths under `data/`, the shape of each file (fields, one-record-per-line vs. one-file-per-item), and any invariants an editor must preserve. A clear README lets an agent participate in your app's data correctly instead of guessing. ### Live demo (seed sample data) Your gallery detail page has a **"Try it live"** button: it runs the real app in the sandbox against a throwaway, in-memory workspace: no account, nothing saved, gone on refresh. It's the best way for someone to get what your app does, so make it land with sample content instead of an empty screen. Detect the demo session and seed once, on startup, when the workspace is empty: ```js await window.gt.ready if (window.gt.mode === 'demo' && (await gt.listFiles()).length === 0) { await gt.writeFile('v0/items.jsonl', '{"text":"Buy milk","done":false}\n…') // …a few realistic records so the demo shows the app at its best } ``` - `gt.mode` is `'demo'` in the live demo (and in the App Builder preview) and `'live'` in a normal workspace. It's set by the time `gt.ready` resolves. - Only seed when the workspace is **empty**, never overwrite a real user's files. - Demo writes go to the same throwaway hub; they're never persisted, so seeding is free to be generous. ### Opened outside General Text: the standalone splash + demo Your built app can also be visited **directly on its own deployed origin**: someone follows the link on your repo, or lands on the URL. There is no General Text around it, so **nothing injects `window.gt`**, and an app that calls `await window.gt.ready` at startup just hangs on a blank screen with `Uncaught TypeError: window.gt is undefined` in the console. Handle it: this is the first impression for anyone who meets your app outside the gallery. **The expectation (every first-party app does this):** when there's no runtime, render a small splash that says the app runs inside General Text and how to install it, and offer a **"Try the demo"** button that boots a local in-browser workspace and shows the **same seeded sample data** as the gallery demo. Gate your mount on the runtime existing, and only load it yourself (with a local workspace) when the visitor opts into the demo: ```js // main.tsx — window.gt is present iff a host injected it (inside General Text, or the // dev plugin during `pnpm dev`). If it's absent, we're standalone on the deployed site. const HAS_RUNTIME = typeof window !== 'undefined' && !!window.gt function loadRuntime() { return new Promise((resolve, reject) => { if (window.gt) return resolve() window.__gtConfig = { local: true } // force a local in-browser workspace window.__demo = true // your own marker → seed sample data (see below) const s = document.createElement('script') s.src = 'https://www.generaltext.org/__gt/runtime.js' // must load BEFORE you mount s.onload = () => (window.gt ? resolve() : reject(new Error('runtime loaded but no window.gt'))) s.onerror = () => reject(new Error('failed to load the runtime')) document.head.appendChild(s) }) } if (HAS_RUNTIME) mountApp() else renderSplash({ onTryDemo: () => loadRuntime().then(mountApp).catch(renderSplash) }) ``` Then widen the seed check from the section above so the standalone demo fills too, not only the gallery: ```js if ((window.gt.mode === 'demo' || window.__demo) && (await gt.listFiles()).length === 0) { // …seed the same sample data } ``` - The runtime must be on `window` **before** your app mounts and calls `gt.ready`; load it inside the promise above, not after you render. - Inside General Text and in `pnpm dev` the runtime is already injected, so `HAS_RUNTIME` is true and none of this runs, so behavior there is unchanged. - This is the same `window.__gtConfig = { local: true }` self-hosted-demo mechanism described under Developing and testing on the [Building Apps](/docs/building-apps) page, driven by a button instead of a `/demo` route.