The window.sg bridge
Inside App Mode, the host injects a bridge object — window.sg — that gives your page mediated access to the vault and to host capabilities. It installs synchronously while <head> parses, so any inline script can use it. The app frame never holds the vault key: every operation goes through the host, which owns the crypto and the consent UI.
Namespaces at a glance
| Namespace | What it does | Grant |
|---|---|---|
sg.vfs | read / readText / list / write / host-fulfilled download. Writes cap at ~3 MB (EFBIG); reads have no cap | fs.read / fs.write |
sg.loadCss / sg.loadJs | Top-level functions (not under vfs) — the sanctioned way to load vault CSS/JS; injects into <head> | rides fs.read — no separate verb |
sg.fs | move (grant needed on both paths) / delete / mkdir | fs.* + first-use consent |
sg.vault | Cross-vault lifecycle: create(opts) (options object — label, seedFrom, returnKey, custody, link), getKey, openApp, list, unlink, mount, embed | vault.*; embed needs none — the key is the capability |
sg.history | Read-only commit history: log, list, read/readText at a commit — works on read-only opens | no |
sg.sync | status / push / pull / refresh against the named branch (sg.git.* survives as a deprecated alias) | writable vault |
sg.auth | The write gate — the server access token, separate from the encryption key: hasKey, setKey, check, clear | no |
sg.ui | Host toasts (message), quick-look preview (incl. PDFs — in-frame PDF rendering is sandbox-blocked), requestPermission | no |
sg.state | Device-local prefs (64 KiB/key, kernel localStorage, survives reload, not a vault write — "a theme toggle shouldn't create a commit") | no |
sg.llm | available / models / chat (streaming, cancel, image parts) / usage / voice listen — the vault's LLM key stays in the host; your frame never sees it | llm.chat / llm.listen / … |
sg.append | Append-only transport (write verb is write): configure, write, list, fetch, markProcessed, purge. Lives outside the commit tree | append.* |
sg.on / sg.off | Host events pushed to the app (e.g. append.new-messages on tab focus — the kernel checks; your app does not poll) | host_events allowlist (top-level key) |
sg.app | Read-only context: writable, selfPath, vaultName, vaultId, context | no |
The permission model
Grants live in app.json (preferred location: .vault/app.json; a legacy root app.json is still read). Each permission is true | false | string[], and path lists are prefix-based, not glob: a trailing / means "this folder and everything under it"; no slash means an exact file. "data/**" is not valid — use "data/".
{
"entry": "index.html",
"permissions": {
"fs": { "read": true, "write": ["data/"], "delete": ["data/"] },
"vault": { "create": ["runs/"] },
"llm": { "chat": true },
"externalLinks": true,
"downloads": true
},
"host_events": ["append.new-messages"]
}
- Mutations are deny-by-default. Writes, deletes, vault lifecycle, append and llm all throw
EPERMwithout a grant. Reads are default-allow today — and are planned to flip to deny-by-default, so declarefs.readanyway. - Two independent gates on every mutation: the server access token (is the vault writable at all?) and the
app.jsongrant. The manifest is a hard ceiling — an app can only ever do what it declares. - The floor is never grantable:
.vault/**is invisible and untouchable (EPROTECTED) — which is also why an app cannot edit its own manifest: the manifest is the grant. - Consent is host chrome. Grant-gated verbs raise the host's own confirmation, cached per (vault, app, verb);
permissions.consentcan tune (never widen) the prompting. Apps can't fake or suppress it. - LLM calls are budgeted: the host clamps
maxTokens, filters models, enforces spend caps (EBUDGET) — callsg.llm.available()before rendering any AI UI, and render costs as estimates (~), never as a bill. Deliberate design: there is no tool-calling loop — the LLM never gets ambient authority over the vault; your app decides what to do with a reply, under its own grants.
Error codes worth branching on
| Code | Meaning |
|---|---|
EPERM | verb not granted in app.json |
EPROTECTED | the floor — .vault/** or the manifest itself |
ECONSENT | user declined the consent prompt |
ENOENT | no such file — the usual cause when loadCss/loadJs "doesn't work" (typo, or written locally but never pushed) |
EFBIG | single write over ~3 MB |
EREADONLY | static host or read-only session |
EBUDGET · ENOKEY · EMODEL · EABORT | llm namespace: spend cap, no key configured, model not allowed, cancelled |
Host chrome vs API — two different things
Chrome you configure but never call (via app.json's hud block — mode: full | minimal | hidden | none plus per-button show.* flags): the nav row, Print, the ✨ AI chat panel (text and voice, entirely at the host origin — your frame never sees the conversation, the key, or the microphone), the file-activity meter, friendly 404 overlays, and the external-link confirm. API you call, with grants: sg.ui.preview, sg.vfs.download, sg.llm.*.
And the sovereignty rail — three things an app can never suppress, whatever its hud config: consent prompts always render; mode:"hidden" keeps a corner exit pill; and the user can force the full HUD back on from their side, at a level app code cannot reach. The HUD config is for app preferences, not app authority.