General Text & Computing Company

Building Apps

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 (the file API your app talks to), Real-time Collaboration (the structured-CRDT path, for when you need it), and Publishing to the Gallery.

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; 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 <script type="application/gt+json"> for a single file.
  4. 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 rest of this page is the fastest path from nothing to a working, installed app. The API those apps call is documented in The window.gt Runtime.

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 = 'v1/note.md' // relative to your app's data folder (see The window.gt Runtime)
      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 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):

{
  "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": "<svg …>…</svg>" } 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), 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.xv0/). 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). 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) — 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):

// 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.)

If your app uses the structured-sync or presence APIs (so it aliases yjs / y-protocols/awareness to /__gt/*; see Real-time 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.

<script>
  window.__gtConfig = { local: true } // force a local workspace off-localhost
</script>
<script src="https://www.generaltext.org/__gt/runtime.js"></script>

(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).

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 <link rel="modulepreload"> 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:
// 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 <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). No localStorage for 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.json at the site root (multi-file) or an inline <script type="application/gt+json"> (single-file), with a unique name.
  • Works inside an iframe (no top-level navigation assumptions; read context from window.gt, not from cookies or your own origin).
  • No native window.confirm / window.prompt / window.alert. The sandboxed iframe omits allow-modals, so these silently do nothing (confirm()false, prompt()null) with no dialog and only a console warning — a delete gated on if (window.confirm(...)) just never fires. Build in-app confirmations and text inputs instead.
  • Doesn't hang when opened outside General Text: if window.gt is absent, show the standalone splash + "Try the demo" (local workspace, seeded), not a blank "Loading" (see Publishing to the Gallery).
  • Your host sends Access-Control-Allow-Origin: * (a _headers file on Cloudflare); web installs fetch your files from the browser and fail without it.