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, reads its workspace context from the URL, and talks to files through one small client library (gt-sync). You build it, host the static files, and install it by URL. 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 SPA that reads/writes those files via
gt-sync. - You ship a
gt.jsonmanifest at the root of your built site. - That's it. General Text 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.
Quickstart
my-app/
index.html # single entry point
src/main.ts # your app + gt-sync usage
src/gt-sync.ts # copy from General Text (see "The client" below)
gt.json # manifest
package.json # deps: yjs, y-protocols, lib0 (+ your framework)
vite.config.ts # or any bundler that outputs assets under /assets/
gt.json (served at the site root):
{
"name": "tally",
"displayName": "Tally",
"version": "1.0.0",
"extensions": ["tally"]
}
name: lowercase letters, digits, hyphens. Unique; it's your app's id and its storage folder (_gtApps/tally/).displayName: shown to users.version: semver. Bump the major only when you break your file format (rare). See "Versioning" below.extensions: file extensions your app edits, without the dot. Optional — 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.
The client: gt-sync
Copy gt-sync.ts into your app (it's a single dependency-light file; deps: yjs, y-protocols, lib0). It auto-reads the workspace context that General Text puts on your iframe URL — you don't wire anything up.
import { createGTSync } from './gt-sync'
const sync = createGTSync() // reads workspaceId/token/urls from the URL
await sync.connect() // opens the realtime sync connection
Live editing (the main path)
subscribeFile(path) returns a Yjs Y.Text for that file. Edits to it sync to every other client automatically — no save button, no conflicts.
const text = sync.subscribeFile('notes/today.md')
// render current content
render(text.toString())
// react to remote (and local) changes
text.observe(() => render(text.toString()))
// write: mutate the Y.Text (use applyDiff for whole-string replacement)
import { applyDiff } from './gt-sync'
applyDiff(text, text.toString(), newValue)
For a real text editor, bind the Y.Text to a CRDT-aware editor binding (e.g. y-codemirror.next) instead of diffing strings yourself.
The file list
sync.getFilePaths() // string[] of paths in the workspace
sync.observeFilePaths((paths) => ...) // re-runs when files are added/removed
sync.getFileMeta(path) // { sizeBytes, version }
One-shot reads/writes (when you don't need live sync)
await sync.readFile(path) // → string
await sync.writeFile(path, content) // whole-file write
await sync.deleteFile(path)
await sync.listFiles() // → [{ path, sizeBytes }]
Use subscribeFile for anything the user edits live; use these for one-off operations (importing, generating a file, deleting).
Auth
const user = await sync.checkAuth() // → { id, name, email } | null
if (!user) {
const url = sync.getLoginUrl() // redirect here (null on desktop — the shell handles it)
if (url) window.location.href = url
}
Most apps don't need this — if your iframe loaded, the user is already in a workspace. Use it only if you want to greet them or gate on sign-in.
Connection state
sync.on('connected', () => ...)
sync.on('disconnected', () => ...)
sync.on('mode-changed', (mode) => ...) // 'realtime' | 'offline' (desktop offline)
Where your data goes
- Your app's private data lives under
_gtApps/{name}/— config, caches, documents your app owns outright. A self-contained app (say, a habit tracker) keeps everything here. - User files at the workspace root (
notes/,*.md, etc.) are the user's corpus. An app that edits user files (a Markdown editor) reads/writes those paths directly. (A future grants system will scope this; today an app can read the workspace it's opened in.) - App data is versioned by format major:
_gtApps/{name}/v{major}/.... You almost always stay atv1. Only bump when you make a breaking format change; a major bump copies the previous major's data into the new folder so old versions keep working.
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). Just run your app's own dev server (e.g. Vite on http://localhost:5180) and open it in a normal tab. With no host on the URL and on localhost, createGTSync() 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.
Local mode stubs the whole host context, so you have something real to design against with nothing but your repo checked out — no account, no server, no setup:
- User —
checkAuth()returns a stub user (override withdevUser). - Workspaces —
listWorkspaces()/fetchWorkspaces()return stub workspaces instead of hitting the network (override withdevWorkspaces).connect(id)switches between them, each with its own local store, so a workspace-picker UI works. - Files — seed them with
devSeedso there's content on first run.
const sync = createGTSync({
devUser: { id: 'u1', name: 'Ada', email: 'ada@example.com' },
devWorkspaces: [
{ id: 'personal', name: 'Personal' },
{ id: 'team', name: 'Team' },
],
devSeed: { 'notes/today.md': '# Today\n\n- ' },
})
await sync.connect() // first devWorkspaces entry by default; connect('team') to switch
All of these are inert under a real host: a launched app gets its user, workspace, and files from General Text. You write the app once; the stubs only fill in when there's no host.
- A small dev panel (bottom-right) lets you reset the workspace or mirror it to a real folder on disk (Chromium — File System Access API). Mirroring is two-way: files appear as plain
.md/.csvyou can open in any editor, and external edits flow back into the app. Wire your own buttons withsync.dev?.mirrorToFolder()/sync.dev?.reset(), or hide the panel withcreateGTSync({ devPanel: false }). Checksync.isLocalto branch dev-only UI.
Local mode is auto-detected; force it with createGTSync({ mode: 'local' }) or turn it off with mode: 'remote'.
Tier 2 — Build check. vite build and vite preview to confirm assets resolve under /assets/ and gt.json is at the site root. Still standalone (local workspace) — this verifies your bundle, not the host.
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 locally (the
vite previewURL above),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 an iframe with the workspace context on the URL, so
createGTSync()connects 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). - All persistence goes through
gt-sync(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).
-
gt.jsonpresent at the site root with a uniquename. - Works inside an iframe (no top-level navigation assumptions; read context from
createGTSync(), not from cookies or your own origin).
A complete minimal example
A plain-text scratchpad bound to one file, no framework:
<!-- index.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Scratch</title>
</head>
<body>
<textarea id="ed" style="width:100%;height:100vh"></textarea>
<script type="module" src="/assets/main.js"></script>
</body>
</html>
// src/main.ts → built to /assets/main.js
import { createGTSync, applyDiff } from './gt-sync'
const sync = createGTSync()
await sync.connect()
const path = '_gtApps/scratch/note.md'
const text = sync.subscribeFile(path)
const ed = document.getElementById('ed') as HTMLTextAreaElement
let applying = false
const rerender = () => {
if (applying) return
if (ed.value !== text.toString()) ed.value = text.toString()
}
text.observe(rerender)
rerender()
ed.addEventListener('input', () => {
applying = true
applyDiff(text, text.toString(), ed.value)
applying = false
})
// gt.json
{ "name": "scratch", "displayName": "Scratch", "version": "1.0.0" }
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, and no database.