Create Apps
App Creator
The easiest way to build apps is by using freezr's App Creator, which helps you build, preview, and publish freezr apps directly from your browser. Every app lives under a project folder on your freezr server. You write plain HTML, CSS, and JS — there's no build step and no bundler required. (See below for writing in JSX / React) Prefer your own editor? You can also develop apps locally with an AI coding agent like Claude Code or Cursor — see below.
Ask App (Query your apps)
Any user can also query any app's data. Ask any question and the LL will figure what data from which app it needs to query to answer your question and it builsds a page with graphs and charts to answer your question. Under the hood, this is actually a new mini app, an "ask app" hat has the same structure as a regular app, just separated under a different view so as not to clutter your home page.
How it works
How it works
Whichever way you create your apps, you should be aware of the way freezr pulls together pages and data. Each app contains one or more pages. A page is an HTML file plus optional CSS and JS resources. When a visitor loads the page, freezr injects your resources into a standard HTML skeleton and serves the result. You can define the reseources associated with each page in the app manifest. (Details below) Your JS can call freezr.query(), freezr.create(), and other API methods in your code — the global freezr object and its API are always available, as defined below.
Develop with an AI coding agent
Besides the in-browser App Creator and Ask-App Creator, you can develop a freezr app in your own editor, with an AI coding agent doing the heavy lifting — Claude Code, Cursor, Copilot or ChatGPT. If you run freezr locally, each app is just a folder at users_freezr/<user>/apps/<app.name>/: point your agent at that folder and use the three developer helpers on your freezr home page (Install Existing Apps → Dev tab, visible to admin users).
The context file — freezr-context.md
freezr-context.md is a single generated document containing everything an AI assistant needs to write freezr apps: the hard rules, the manifest format, the full freezr API reference and the permission patterns — the same guidance the in-browser App Creator gives its own model. Press "(re)Generate context file for LLMs" on the Dev tab to place the latest copy in an app's folder (copies are also refreshed automatically whenever an app is updated). Then attach it to your assistant:
- Claude Code — create a
CLAUDE.mdat the app folder root containing one line:@freezr-context.md. It is auto-loaded every session. - Cursor — save a copy as
.cursor/rules/freezr.mdc, or add a rule pointing tofreezr-context.md. - GitHub Copilot / Codex / other agents — rename or symlink the file to
AGENTS.md. - ChatGPT (web) — upload
freezr-context.mdto your conversation or Project files.
You can also read the current version directly from any up-to-date freezr server at /app_files/info.freezr.public/freezr-context.md.
Dev API tokens — .freezr-access.local.json
An agent that only edits files is coding blind — to verify its work it needs to query and write the app's data on the running server. Press "Regenerate Tokens for App" on the Dev tab and freezr writes a long-lived CEPS token (read + write on that app's own tables, expiring after the server maximum — 6 months) into the app folder as .freezr-access.local.json, together with ready-made curl examples and regeneration instructions:
{
"baseUrl": "http://localhost:3000",
"userId": "you",
"appName": "com.you.myapp",
"appToken": "<long-lived token — Bearer auth on /ceps/* >",
"appTokenExpires": "<ISO date>",
"examples": { "queryTable": "<curl>", "writeRecord": "<curl>", "updateAppFromCode": "<steps>" },
"howToRegenerate": "<where to mint a replacement>"
}
With that file in place the agent can verify end-to-end, e.g.:
curl -X POST "$baseUrl/ceps/query/com.you.myapp.notes" \
-H "Authorization: Bearer $appToken" \
-H "Content-Type: application/json" -d '{"count":5}'
.gitignore, and the generated freezr-context.md instructs assistants never to copy token values into committed files, and to ask you to regenerate from the Dev tab when a token is missing or expired.
Re-install from files
After your agent edits manifest.json (new tables, pages or permissions), freezr needs to re-read it. Press "Regenerate App from Files" on the Dev tab — this also adds a brand-new app folder to your apps list the first time. For one-click convenience, opening /account/home?devUpdateApp=<app.name> in your logged-in browser jumps to the Dev tab with the app pre-selected. (Deliberately not automatic: re-installing always takes an explicit click.)
App manifest
Every freezr app should ship a manifest.json read at install time. It describes the app's identity, the pages it serves, the data tables it needs, and the permissions it requests from the user or from other installed apps. (Simple apps can run without a manifest as freezr defaults to checking for the page namesake js css and html files.)
Metadata fields
| Field | Type | Description | |
|---|---|---|---|
| identifier | string | required | Reverse-domain app name, e.g. com.you.myapp. Also the folder name under users_freezr/<user>/apps/. |
| display_name | string | required | Human-readable name shown in the app list |
| description | string | optional | Short description shown in the app listing |
| version | string | optional | Semver version string |
| meta.only_use_collections_listed | boolean | optional | When true, freezr enforces the declared app_tables — writes to undeclared tables fail. Recommended for catching typos early. |
| manifest_url | string | optional | Canonical URL of the manifest zip file (if available). |
| authorship | object | system-set | Provenance record — who created the app, who edited it, what it was cloned from. Stamped and maintained by the freezr server when using app creator. See Authorship & provenance. |
Authorship & provenance
freezr's App Creator stamps every manifest with an authorship object recording the
app's provenance: it is written when an app is created in the App Creator, updated when someone edits an app's
files, and carried across when an app is cloned. When building apps outside of the App Creator, App developers
should follow the same best practices; when rewriting manifest.json, pass the existing
object through unchanged. Every identity in it has the shape { id, host, date } — the user id, their
freezr server, and the ms-epoch time of the stamp. Two identities are the same person when id and
host match (host compared ignoring scheme and trailing slashes).
"authorship": {
"main_author": { "id": "salman", "host": "https://myserver.com", "date": 1756... },
"last_modified": { "id": "alice", "host": "https://her-server.net", "date": 1756... },
"contributors": [ { "id": "alice", "host": "https://her-server.net", "date": 1756... } ],
"forked_from": { "app": "com.salmanff.orbit",
"author": { "id": "salman", "host": "https://myserver.com", "date": 1756... },
"version": "0.13", "date": 1756... },
"history": [
{ "event": "created", "by": { "id": "salman", ... }, "date": 1756... },
{ "event": "forked", "by": { "id": "alice", ... }, "date": 1756...,
"from_app": "com.salmanff.orbit", "from_author": { "id": "salman", ... }, "from_version": "0.13" },
{ "event": "edited_by", "by": { "id": "bob", ... }, "date": 1756... }
]
}
main_author— the creator; should not be changed by edits. Set once, at creation or fork (the person who forks owns the new app). On a legacy app that predates authorship, edits never auto-assign it — claiming authorship of an authorless app is a deliberate, explicit act.last_modified— the most recent editor, updated on every edit-and-reinstall by a different person.contributors— everyone other thanmain_authorwho has ever edited the app, one entry each; thedateis their first edit.forked_from— present only on cloned apps: the source app id, the source'smain_authorat fork time, the source'sversion, and when the fork happened.history— an append-only handover log, not an edit log. Events arecreated,forked, andedited_by— whereedited_byis recorded only when the editor changes (same-person re-edits add nothing), so the list reads as "who worked on this app, in order". A fork copies the source's history and appends itsforkedevent, so lineage survives chained forks. Capped at 50 events (oldest dropped).
Pages
The pages object defines HTML pages served directly from the freezr server. Each entry is keyed by page name (e.g. index) and resolves to /apps/<app_id>/<page>. Page HTML files contain inner-body markup only — no <!DOCTYPE>, <html>, <head> or <body>. freezr wraps them with the document skeleton at render time.
| Field | Type | Description | |
|---|---|---|---|
| page_title | string | required | Browser tab title |
| html_file | string | required | Inner-body HTML filename |
| modules | array | optional | ES-module JS file(s) injected as <script type="module"> with the page nonce |
| script_files | array | optional | Non-module JS file(s) injected as plain <script> tags |
| css_files | array | optional | CSS file(s) linked in the page <head> |
| initial_query | object | optional | WIP. Pre-populates the page server-side using Mustache.js conventions |
App tables
The app_tables object declares the data tables (collections) the app uses. freezr creates them at install time and scopes them to the app's identifier: the actual table name is <app_id>.<table>, so com.you.notes's entries table is stored as com.you.notes.entries.
"app_tables": {
"entries": {
"strictly_Adhere_To_schema": false,
"field_names": {
"title": { "description": "Note title", "type": "String" },
"body": { "description": "Markdown body", "type": "Text" }
}
}
}
Tables are private to the app by default. Sharing with other apps requires a permissions declaration (below) plus the user granting it at install time.
Permissions
An app may request access to another installed app's tables, or to access the user's LLM or serveless instances, or to relax freezr's CSP policies, via the permissions object. Users can also give permission to other users to access specific data records or whole tables, and / or makle them publicly accessible. Each user must approve the permissions requested by apps at install time or later via the Account app. Granted permissions can be revoked at any time.
| Permission | Grants |
|---|---|
| read_all | Read all records in another app or user's table (broad scope) |
| write_all | Create / update records in another app or user's table |
| write_own | Read al and create records in another app or user's table, and be able to update the records that user/app has created. |
| share_records | Share specific records with other users on the same server. Once this permission is given to the app, the app is trusted to decide which persons to share with. |
| db_query | WIP allow apps or users to access records based on specific query criteria. |
| message_records | Send record-linked messages to other users |
| upload_pages | Create an html page which the app can reconstruct with specified js and css files. (This page was created with the upload_pages premission.) |
| allow_self_frames | Relax CSP to allow same-origin and blob: iframes |
| external_fetch | Relax connect-src to whitelisted external domains |
| external_scripts | Allow external scripts with SRI hashes |
| unsafe_eval | Allow eval() / new Function() |
| use_llm | Allow the app to use your LLM API key to ask your LLM to perform any task. There is no separate permission for the things that travel out under this grant: web searches send prompt-derived queries to a search engine and pull external pages into the conversation, and transcribe() uploads recorded audio. Apps that do either should say so in their permission description. |
| use_mail | Read / send mail via the user's connected mail accounts. Takes
connection_names (["*"] = all, user can narrow at grant time) and
scopes (["read"] or ["read","write"]); writes are also gated
user-side by the connection's access.mail. |
| use_messaging | Read / send messages via the user's connected messaging accounts (Slack first —
channels, DMs, group DMs, threads). Same shape and two-level gating as use_mail
(connection_names + scopes; access.messaging). Includes the
getChanges live-updates activity index where the server has messaging sockets enabled.
See Messaging (connections). |
| socket_connect | Reserved — not yet executable. Will let the freezr server hold a persistent
connection (socket) to a named external domain on the app's behalf, delivering its data to the app —
subject to the user's grant AND an explicit admin admission on that server. Declaring it today is
harmless but grants report blocked_by: 'socket_not_admitted'. |
| run_job | Run a background job on demand (when you or the app trigger it). You choose where it runs — on this server (if an admin has trusted it) or on your own cloud. |
| schedule_job | Run a background job automatically on a recurring schedule. Independent of run_job; the app starts the schedule when it's meaningful. |
The freezr global
Every app page has freezrApiV2.js injected before any app script. It defines a global freezr (and window.freezr) object exposing the full client API. Apps do not import it — they call it directly.
The global API is organised as:
| Namespace | Purpose |
|---|---|
| freezr.create / read / update / updateFields / delete / query | Record CRUD |
| freezr.collection(name) | Bound helper for a single table |
| freezr.publicquery | Query another user's public records |
| freezr.upload / getFileUrl / deleteFile | File operations |
| freezr.perms.* | Permission grants, record sharing, public file sharing |
| freezr.messages.* | Cross-user messaging linked to records |
| freezr.llm.* | LLM completions and image generation using the user's stored keys |
| freezr.jobs.* | Run background jobs on demand, start/stop schedules, and check job status (local "trusted" or the user's serverless cloud) |
| freezr.utils.* | Helpers: capability ping (grants + what actually works), cookie reading, manifest lookup, resource usage, path building |
| freezr.apiRequest | Low-level HTTP helper for custom endpoints |
freezr.initPageScripts hook is called once on window.load. Apps assign a function to it to run their boot code — this guarantees the API is ready and the page DOM is parsed.
All functions return a Promise. Most accept (primaryArgs, options) where options is an object with optional keys (appToken, host, owner_id, permission_name, requestee_app, etc.). Legacy callback-based methods (freezr.ceps.*, freezr.feps.*) exist only for backward compatibility and log a deprecation warning.
CRUD operations
The first argument is either a bare collection name (prefixed automatically with the calling app's identifier) or a full <app_id>.<table> reference when using requestee_app to access another app's data.
"notes") or full "com.other.app.items"._id is assigned server-side unless data_object_id is passed.freezr.utils.tokenizedFileUrl(fileId) instead (below) — this bare URL will 401 unless the file has been shared publicly.// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id> (no token needed once public)
const url = await freezr.utils.tokenizedFileUrl(id) // => /feps/userfiles/<app>/<user>/<id>?fileToken=…
_id.share_records / read_records permission).const note = await freezr.read('notes', 'abc123')
await freezr.read('notes', 'abc123', { owner_id: 'alice', permission_name: 'shared_notes' })
await freezr.update('notes', 'abc123', { title: 'Updated', body: 'New body' })
_id for single-record update, or a query object to update all matching records.await freezr.updateFields('notes', 'abc123', { done: true } )
await freezr.updateFields('notes', { archived: false }, { archived: true } )
await freezr.delete('notes', 'abc123')
await freezr.delete('notes', { archived: true } )
create, read, query, update, updateFields, delete pre-bound to name — convenient when a component only touches one table.const notes = freezr.collection('notes') await notes.create({ title: 'Hi' }) const all = await notes.query({})
Querying records
$where, $function, $accumulator, $expr) are rejected server-side.{ _date_modified: -1 }.const rows = await freezr.query( 'notes', { archived: false }, { sort: { _date_modified: -1 }, count: 20 } )
await freezr.publicquery({ owner: 'alice', app_table: 'com.you.blog.posts', count: 5 })
Files
Binary files are stored through the configured file-system adapter. File metadata (including _id and publicid) lives in the app's files collection. A public file (shared via freezr.perms.shareRecords(..., { grantees: ['_public'] })) is served openly at its public URL. A private file — the default — is served only to a request carrying a short-lived, scoped fileToken (in the URL) or your app's Authorization: Bearer token; the browser's ambient cookie is not accepted, so a bare <img src> to a private file returns 401.
<img>/<video>/CSS loads can't set an Authorization header, so private files need a tokenized URL — use freezr.utils.tokenizedFileUrl(fileId) (or freezr.utils.refreshFileTokens() to tokenize a whole page at once), not the bare freezr.getFileUrl(...) below. freezr.getFileUrl alone is only safe once a file has been shared publicly.
File object (from <input type="file">) or a Blob.const file = evt.target.files[0] const res = await freezr.upload(file) // => { _id, publicid, filename, size, ... }
// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id>
'self' (your own app's files). Pass the name of a granted permission to fetch a file another user shared with you.fileToken and appends it as ?fileToken=. Use this for anything a browser fetches on its own — <img>, <video>, <source>. Advanced: freezr.utils.getFileToken(fileId, options?) returns just the raw token.const img = document.createElement('img') img.src = await freezr.utils.tokenizedFileUrl(fileId)
['IMG','VIDEO','AUDIO','SOURCE'].fileToken (e.g. to refresh one about to expire).src attributes pointing at /feps/userfiles/… and rewrites each to a tokenized URL. Call once after rendering a batch of images. freezr.utils.observeFileTokens(options?) does the same, then keeps watching for elements added later (returns a MutationObserver — call .disconnect() when done).await freezr.utils.refreshFileTokens()
const obs = freezr.utils.observeFileTokens() // obs.disconnect() when the view unmounts
freezr.delete('files', fileId). The underlying storage is cleaned up by the file-system adapter.await freezr.deleteFile('img_001')
Permissions & sharing
Granted is not the same as usable — a granted use_llm still fails with no LLM key, and a granted run_job fails if the job has nowhere to run. Start with freezr.utils.ping() to learn, in one call, which grants will actually work and why.
{ logged_in:false, server_type, server_version }. Logged in adds logged_in_as_admin, user_id, storageLimits. Called with the app's token (the default inside an app) it also returns app_name plus:{ name, type, granted, usable, blocked_by } (+ type-specific fields: table_id(s), job_name, location, connection_names, scopes). usable = granted and the server can honor it right now. blocked_by is null when usable, else one of not_granted, no_llm_keys, no_compute_credential, job_not_trusted, no_job_runtime, no_matching_connection.{ llm:{ available }, compute:{ available }, connections:{ mail, contacts, calendar } } (connection values are counts).const status = await freezr.utils.ping() const ai = status.permissions.find(p => p.name === 'ai_access') if (ai?.usable) { // safe to call freezr.llm.ask() } else if (ai?.blocked_by === 'not_granted') { // ask the user to grant it in Settings } else if (ai?.blocked_by === 'no_llm_keys') { // granted — send them to add a key in Account Resources }
{ name, granted, description, ... }. Also available: freezr.perms.isGranted(name) for a simple boolean check.const perms = await freezr.perms.getAppPermissions() if (await freezr.perms.isGranted('share_records')) { // show share UI }
Messages
<app_id>.<table> containing the record.await freezr.messages.send({ recipient_id: 'bob', table_id: 'com.you.notes.entries', record_id: 'abc123', sharing_permission: 'shared_notes', contact_permission: 'contact_users' })
const msgs = await freezr.messages.getAppMessages() await freezr.messages.markRead(['m1', 'm2'])
LLM
freezr proxies LLM requests through the user's own keys (stored server-side in the account settings). The API abstracts Claude and OpenAI behind a single interface with streaming support via SSE. Text generation, attachments and image generation work the same on either provider.
Beyond text, the providers differ — so "does the user have a key?" is the wrong question. Voice is ChatGPT-only. Web search exists on both but takes different options. Check the capability, not the key: freezr.utils.ping()'s capabilities.llm.available tells you a key exists, freezr.llm.can('web') tells you whether the feature will actually work, and freezr.llm.ping() returns the full provider / model / pricing detail.
can() returns true, false, or the string 'unknown'. 'unknown' means the provider has published no answer for that model — treat it as try it and handle the error, never as a no. freezr deliberately keeps no hand-written table of which model supports what, so an undocumented model resolves to 'unknown' and is allowed through rather than being silently denied a feature it may well have.
{ role, content } for a conversation history.'Claude' or 'ChatGPT'.'sonnet', 'opus', 'mini'...), specific model id, token cap.streamBack: true, chunks are delivered via the callbacks as they arrive.res.thinking, or via onThinking when streaming) — it is a summary the model writes of its
own reasoning, never the raw chain of thought. Pass false to switch reasoning off: recent models
reason by default and those tokens bill as output, which matters for high-volume extraction.'json' to auto-parse a JSON response. Incompatible with streamBack.true works on both providers;
anything more specific differs (see the table below). On a provider that cannot, the call fails
with code: 'capability_unsupported' rather than quietly answering without the web — a stale answer that
looks current is the worse outcome. Pass { optional: true } to prefer an answer anyway, then read
meta.capabilities.unavailable. Costs real money — see the callout below.'low' / 'medium' / 'high'. Trades thoroughness against
token spend across everything the model writes, reasoning included. Takes precedence over thinking.effort
if both are given.true, or { ttl: '1h' }. Pays off when a follow-up re-sends the same prefix and only appends — e.g. repeated questions over one large document. The prefix must be byte-identical between calls. Claude only; ChatGPT caches automatically with nothing to set. Cached-token counts and cost appear in meta.tokensUsed.other.const res = await freezr.llm.ask('Summarise', { context: 'You are a concise assistant', family: 'sonnet' }) console.log(res.response, res.meta.tokensUsed)
await freezr.llm.ask(prompt, { streamBack: true, onDelta: (t) => appendToUi(t) })
const img = await freezr.llm.generateImage( 'A cat in a wizard hat, watercolor', { size: '1024x1024' } ) // img.b64Data is a PNG base64 string
'web', 'web.search', 'web.fetch', 'vision', 'documents', 'voice.stt', 'voice.tts', 'thinking.text', 'effort'.web, where freezr refuses rather than switching the provider that answers your prompt.'unknown' means try it, not no. Backed by a memoized ping, so asking repeatedly costs one request. freezr.llm.capabilities() shares that same ping and returns { capabilities, defaultProvider } when you need to know which provider will serve a request.if (await freezr.llm.can('web')) showWebToggle() if (await freezr.llm.can('voice.stt', { anyProvider: true })) showMic()
try { await freezr.llm.ask(prompt, { web: true }) } catch (e) { if (e.code === 'capability_unsupported') { // e.capability 'web' // e.supportedBy ['Claude'] — only keys this user holds } } // Nothing is charged: the check runs before the provider is called.
const r = await freezr.llm.ask(prompt, { web: { search: { maxUses: 3 } } }) r.meta.capabilities.unavailable // on ChatGPT: ['web.search.maxUses'] — it has no cap
Blob (e.g. from MediaRecorder), or { fileName, mimeType, contentBase64 } from a background job. The file extension must match the container the recorder produced — Safari records mp4 where Chrome records webm.'en'). Worth passing: it improves accuracy and cuts latency.voice: 'alloy' (default), 'ash', 'coral', 'sage', 'marin'… format: 'mp3' (default), 'opus', 'wav', 'pcm'… instructions: how to say it ("calm and slow").const { text } = await freezr.llm.transcribe( audioBlob, { language: 'en' } )
const { format, b64Data } = await freezr.llm.speak('Your invoice is ready.') new Audio('data:audio/' + format + ';base64,' + b64Data).play()
capability_unsupported before anything is charged — gate them with can('voice.stt', { anyProvider: true }). If the user's default provider cannot do voice but another of their keys can, freezr uses the capable key: the provider here performs a mechanical transform rather than answering a question, so substituting it changes nothing about the result. meta.provider reports which key was spent — worth showing the user, since it may not be the provider they chose.
What differs between the providers — everything not listed here behaves identically on both.
| Capability | Claude | ChatGPT |
|---|---|---|
| voice.stt / voice.tts | ✗ no speech model | ✓ |
| web.search | ✓ separate tool | ✓ one tool that also opens pages |
| web.fetch | ✓ separately requestable — retrieves a URL already in the conversation; it cannot go and find one | Not separable — asking for fetch enables search too |
| web search options | maxUses, allowedDomains, blockedDomains, maxContentTokens, userLocation | allowedDomains and userLocation only — no cap of any kind |
| cache | ✓ explicit, { ttl: '5m' | '1h' } | Automatic — nothing to set |
| images.generate | SVG (converted to PNG server-side) | Raster PNG |
freezr never drops an option silently. Anything you asked for that the provider cannot honour comes back named in meta.capabilities.unavailable — e.g. ['web.search.maxUses']. Read it if you are relying on maxUses as a spend cap.
| Claude (sonnet-5) | ChatGPT (gpt-5.4-mini) | |
|---|---|---|
| web off | 16 input tokens · $0.00003 | 12 input tokens · $0.0003 |
| web on, nothing searched | 7,190 tokens · $0.014 | 4,448 tokens · $0.0010 |
| web on, searching | 282,732 tokens · $0.57 (6 searches, 16 pages) | 8,413 tokens · $0.0212 (2 searches) |
1. Declaring the tools costs input tokens on every call, even when nothing is searched — ~7,200 on Claude, ~4,400 on ChatGPT. A fixed tax, so it hurts small calls most. Do not leave web on "just in case"; turn it on for questions that need current information.
2. The dominant cost is different on each. On Claude, fetched page bodies flood the context — the searches themselves were only $0.06 of that $0.57, and maxContentTokens is the lever. On ChatGPT the flat per-search fee dominates: $0.020 of that $0.0212 call — 94% — was 2 searches, not tokens. That fee is the same whatever model you pick, so a cheap model does not make web search cheap. And since ChatGPT has no maxUses, the only real control there is deciding at the call site whether web is on at all.
allowedDomains is the per-request mitigation; meta.toolsUsed.webFetch.urls records what was actually fetched, so you can audit what left the conversation.
Mail (connections)
Apps with a granted use_mail permission can read, search, sync, and send mail on the user's connected accounts via the freezr.connections.mail namespace. The user controls which accounts the app sees through a per-app connection picker at /account/resources. Gmail, Microsoft Graph (Outlook / Microsoft 365) and IMAP/SMTP connectors all sit behind the same namespace — the API surface is identical whichever provider a connection uses. The connections.mail.* SDK is loaded on-demand — it is only injected on apps whose manifest declares a use_mail permission.
| Function | Effect |
|---|---|
| freezr.connections.mail.listAccounts() | Connections this app is allowed to see (filtered server-side by granted scope) |
| freezr.connections.mail.listFolders({ connectionName }) | Folders / labels for one connection (Gmail labels → unified shape) |
| freezr.connections.mail.listMessages({ connectionName, ... }) | Paginated message metadata, optionally with attachment manifests |
| freezr.connections.mail.searchMessages({ connectionName, ... }) | Structured search (text, from, to, since, before, labels, isRead, hasAttachments) |
| freezr.connections.mail.getMessage({ connectionName, messageId }) | One full message with bodies + attachment metadata |
| freezr.connections.mail.getAttachment({ connectionName, messageId, attachmentId, ... }) | Raw attachment bytes (Blob by default; pass responseType: 'arrayBuffer' for binary) |
| freezr.connections.mail.getNewer({ connectionName, lastToken }) | Incremental delta sync — added / deleted / label-changed since lastToken |
| freezr.connections.mail.sendMessage({ connectionName, to, subject, bodyText, ... }) | Send a message (requires write scope) |
| freezr.connections.mail.createDraft({ ... }) | Save a draft on the provider (same args as send) |
| freezr.connections.mail.markRead({ connectionName, messageId, isRead }) | Toggle the message's read flag |
| freezr.connections.mail.moveMessage({ connectionName, messageId, targetFolder }) | Move into another folder / label |
| freezr.connections.mail.trashMessage({ connectionName, messageId }) | Send to Trash (recoverable) |
| freezr.connections.mail.deleteMessage({ connectionName, messageId }) | Permanently delete (skips Trash) |
| freezr.connections.mail.handleTokenExpired(resOrErr) | Detect token_expired and navigate to the reauth URL — returns true if handled |
listAccounts()).['INBOX'] for inbox-only.'has:attachment after:1700000000'). Prefer searchMessages for portable queries.true each row carries an attachments: [{ id, filename, mimeType, sizeBytes }] array. Bodies are not returned — use getMessage per row for those.const { messages, nextPageToken } = await freezr.connections.mail.listMessages({ connectionName: 'gmailWork', labelIds: ['INBOX'], limit: 25 })
const page = await freezr.connections.mail.listMessages({ connectionName, q: 'has:attachment filename:pdf', includeAttachments: true }) for (const m of page.messages) { // m.attachments[i].mimeType is untrusted — see "Common pitfalls" }
listMessages / getNewer row.const { message } = await freezr.connections.mail.getMessage({ connectionName, messageId }) // message.bodyHtml & message.bodyText are present when the // MIME tree has them. NEVER pass bodyHtml to .innerHTML // without sanitization — see "Rendering email safely".
nextToken with changes: [] to seed.{ type: 'messageAdded', message } | { type: 'messageDeleted', messageId } | { type: 'labelAdded' | 'labelRemoved', messageId, labels }.nextToken for the next call. expired: true means the provider's delta window elapsed (Gmail ~7 days) — do a full listMessages reload and seed a fresh token.// first call seeds, returns no events const seed = await freezr.connections.mail.getNewer({ connectionName }) saveToken(seed.nextToken) // later, fetch deltas const res = await freezr.connections.mail.getNewer({ connectionName, lastToken: readToken() }) if (res.expired) { // fall back to full reload reloadFolderFromScratch() } saveToken(res.nextToken) res.changes.forEach(applyToLocalView)
write scope in the granted use_mail permission AND the connection's access.mail set to 'readwrite' in /account/resources.{ address, name } are all accepted.multipart/alternative.[{ filename, mimeType, contentBase64 }]. Keep total payload under ~20 MB (provider limits).threadId attaches to the parent thread on the provider; inReplyTo / references populate the corresponding RFC 822 headers for cross-client threading.await freezr.connections.mail.sendMessage({ connectionName, to: ['alice@example.com'], subject: 'Re: Lunch', bodyText: 'Sounds good — 1pm?', threadId: parent.threadId })
async function fileToBase64 (file) { return new Promise(resolve => { const r = new FileReader() r.onload = () => resolve(r.result.split('base64,')[1]) r.readAsDataURL(file) }) } const contentBase64 = await fileToBase64(fileInput.files[0]) await freezr.connections.mail.sendMessage({ connectionName, to: ['team@x.com'], subject: 'Report', bodyText: 'Attached.', attachments: [{ filename: fileInput.files[0].name, mimeType: fileInput.files[0].type, contentBase64 }] })
// detect token_expired anywhere in the namespace try { const res = await freezr.connections.mail.listMessages({ connectionName }) // ... use res ... } catch (err) { if (freezr.connections.mail.handleTokenExpired(err)) return // redirected to /account/resources showError(err.message) }
Background Jobs
Run server-side code without a server in your app. A job is a module at jobs/<name>/index.mjs exporting async function handler (freezr, params) — inside it, freezr is the same client API, so a job is just an API client that runs outside the browser. freezr resolves where it runs: on this server (in-process, admin-trusted) or on the user's own serverless cloud (e.g. AWS Lambda). Jobs are declared in the manifest's jobs array and gated by two independent permissions — run_job (on demand) and schedule_job (recurring); the user also picks the location when granting.
| Function | Effect |
|---|---|
| freezr.jobs.run(name, params?, opts?) | Run a job on demand. name = your own job, or <ownerApp>.jobs.<job> for a third-party job |
| freezr.jobs.schedule(name) | Start the recurring schedule (granting schedule_job is consent only — the app starts it) |
| freezr.jobs.unschedule(name) | Stop the recurring schedule |
| freezr.jobs.ping() | Per-job status: granted? trusted (runs locally)? scheduled? + is the user's cloud available |
// run a job on demand const res = await freezr.jobs.run('process_inbox', { since: lastRun }) if (res.ok) console.log(res.result) // scheduling is app-driven — start it when it's meaningful await freezr.jobs.schedule('process_inbox') // know what you can do before offering job features const info = await freezr.jobs.ping() // info.has_compute · info.jobs.process_inbox.{ run_job_granted, schedule_job_granted, trusted, scheduled, location }
Messaging (connections)
Apps with a granted use_messaging permission can read conversations
(channels, private channels, DMs, group DMs), sync incrementally, send messages and thread replies, mark-read
and delete the user's own messages on the user's connected messaging accounts, via the
freezr.connections.messaging namespace. Slack is the first provider; the API is
deliberately provider-neutral — no Slack-shaped parameters — so app code won't change as other providers land.
The permission model is identical to mail: connection_names + scopes app-side, and a
per-connection access.messaging ('read' | 'readwrite') user-side, both
of which must allow a write. The SDK is injected only on apps whose manifest declares
use_messaging.
// Connections this app may use. access.messaging gates writes user-side;
// live = the user opted this connection into socket-fed live updates.
await freezr.connections.messaging.listAccounts()
// accounts[i]: { connectionName, provider, account_email, services, access, status, live }
// Conversations the connected user is a member of, paginated.
await freezr.connections.messaging.listConversations({ connectionName, limit, cursor, types, includeArchived })
// → { conversations: [{ id, name, type, isMember, isArchived, topic, purpose,
// memberCount, counterpartUserId }], nextCursor }
// type: 'channel' | 'private_channel' | 'group_dm' | 'dm'. DMs have name: null —
// resolve counterpartUserId via getUsers.
// Messages in one conversation, NEWEST FIRST; the cursor pages OLDER.
await freezr.connections.messaging.getMessages({ connectionName, conversationId, limit, cursor, oldest, latest })
// → { messages: [{ id, conversationId, threadParentId, sender: { id, type },
// sentAt /* ms */, text, replyCount, edited, subtype, reactions, files }], nextCursor }
// `id` is the provider's message id — OPAQUE; pass it back verbatim.
// `text` is raw provider markup (Slack mrkdwn) — resolve <@U123> mentions via getUsers.
// Thread REPLIES are not included — only parents (replyCount > 0); fetch them with:
await freezr.connections.messaging.getThread({ connectionName, conversationId, threadId, limit, cursor })
// Per-conversation incremental sync (first call seeds and returns []).
await freezr.connections.messaging.getNewer({ connectionName, conversationId, lastToken, limit })
// → { messages, nextToken, expired }
// Identity helpers.
await freezr.connections.messaging.getUsers({ connectionName, limit, cursor, ids }) // ids: batch ≤100
await freezr.connections.messaging.getConversationMembers({ connectionName, conversationId, limit, cursor })
await freezr.connections.messaging.getProfile({ connectionName })
// profile.userId === message.sender.id identifies the user's own messages.
// LIVE UPDATES — "which conversations changed since X?" without polling them all.
// Metadata only; message content is never stored server-side.
await freezr.connections.messaging.getChanges({ connectionName, since /* ms */ })
// → { changes: [{ conversationId, lastActivityTs, lastEventAt,
// edited: [{ id, at }], deleted: [{ id, at }], changesOverflowed }],
// gaps: [{ from, to /* null = ongoing */ }], gapSince, complete, retentionMs }
// complete: false → the index can't be trusted for your window (server downtime,
// >30-day retention, or an overflowed change list) — do a wider getNewer sweep.
// gapSince non-null → the server's socket is down right now.
// Writes — need 'write' scope AND connection access.messaging === 'readwrite'.
await freezr.connections.messaging.sendMessage({ connectionName, conversationId, text, threadId })
await freezr.connections.messaging.markRead({ connectionName, conversationId, ts })
// read state is a per-conversation cursor — everything at/before ts becomes read
await freezr.connections.messaging.deleteMessage({ connectionName, conversationId, messageId })
// own messages only — others throw with err.data.providerError ('cant_delete_message')
freezr.connections.messaging.handleTokenExpired(resOrErr) // same pattern as mail
Live updates are a server capability, not part of the permission.
Whether getChanges is being fed depends on the server admin having enabled messaging
sockets AND the user having opted the connection in ("Live updates" on /account/resources).
Never assume it — check the capability from freezr.utils.ping() and fall back to polling:
const status = await freezr.utils.ping()
const live = status.capabilities?.messaging_live
// live: { active, sockets_running, live_connections: [connectionName, …] }
if (live?.active && live.live_connections.includes(connectionName)) {
const res = await freezr.connections.messaging.getChanges({ connectionName, since: lastSyncMs })
const targets = res.complete
? res.changes.map(c => c.conversationId) // fetch only what changed
: myTrackedConversationIds // index untrustworthy for window → wider sweep
// getNewer per target; tombstone changes[].deleted ids; re-fetch changes[].edited ids
} else {
// No live updates on this server/connection: poll the conversations you track.
}
Pitfalls: rate limits are shared per provider app per workspace
(Slack internal apps: ~50 history calls/min) — sync a bounded set of conversations, never "all channels every
cycle". Message ids look like timestamps but must be treated as opaque strings. Edits and deletions of old
messages never appear in getNewer — that is exactly what getChanges'
edited/deleted lists are for. complete: false is a normal condition
(server downtime), not an error.
Background Jobs
freezr apps are generally client-side. When an app needs server-side compute — heavy processing, scheduled/recurring tasks, calling external services, or using node modules — it declares a job: a module at jobs/<name>/index.mjs exporting async function handler (freezr, params). The same handler runs in one of two places, chosen by the user, and is reached through freezr.jobs.*.
A job that needs npm packages ships a pre-built node_modules folder alongside its code — freezr copies it into the deployable unit and never runs npm install.
On this server (admin-trusted)
An admin can trust a job so it runs in-process on the freezr server. Because in-process code has full server access, this is a deliberate admin decision (analogous to installing an npm package); the admin also picks the audience — which users may run the trusted copy (admins only / all users / a named list).
On the user's own cloud (serverless)
If the user has added a serverless (compute) credential — AWS Lambda today — the job is deployed to and runs in the user's own cloud account, on their dime. No admin trust is needed: it's the user's code on the user's compute. freezr bundles the job (with its pre-built dependencies) and ships only short-lived run credentials + params at invoke time; the job calls back to freezr over HTTPS for any data access.
- run_job — run on demand. schedule_job — run automatically (
hourly/daily/weekly). Independent consents. - The user chooses where each runs (auto / this server / their cloud) when granting.
- Scheduling is app-driven: granting
schedule_jobis consent only; the app callsfreezr.jobs.schedule(name)to start it.
Building mail/messaging-consuming apps for freezr
Apps that consume mail or messaging (Slack channels, DMs, group DMs) talk to the user's connected accounts through the freezr.connections.mail and freezr.connections.messaging namespaces. Tokens are never visible client-side — the freezr server holds the OAuth grant, refreshes it transparently, and surfaces a structured token_expired payload when re-auth is needed. This guide covers the patterns every mail or messaging app should follow: declaring the right permission scope, rendering untrusted content safely, syncing incrementally, sending, and the subtle correctness traps that catch most apps the first time. The permission, token and revocation model is identical for both namespaces; sections that are mail-only (HTML rendering, attachments) say so, and the messaging-specific API shapes live in Messaging (connections).
Server operators: everything on this page assumes the user has a connected mail account — which requires the freezr server to hold OAuth credentials for Google and/or Microsoft. See setting up mail OAuth.
Working reference implementations ship as the built-in info.freezr.connections / mail and / messaging pages — they exercise every method in their APIs and are the canary for new connectors. Read their source if you want to copy patterns wholesale.
Permissions model
Mail and messaging access are gated on two independent levels — both must allow the action or the call is rejected. This split lets a user keep one account read-only while letting the same app read+write another. Everything below is written for use_mail; use_messaging works identically — same connection_names + scopes fields in the manifest, gated user-side by the connection's access.messaging instead of access.mail, and the token & revocation model further down applies to both unchanged.
- App-side:
use_mailin the manifest. Declares the app's maximum ask. Fields:connection_names(a list of specific connections, or['*']for all) andscopes(['read']or['read', 'write']). The user accepts or denies in the app-settings dialog and can narrow the connection list there. - Connection-side:
access.mailon the connection record. Set per-connection by the user at/account/resources. Values:'read'or'readwrite'. A connection set to'read'rejects every write call even if the granting app haswritescope.
The practical consequence: an app that only needs to read mail should request scopes: ['read']. Apps that send should request ['read', 'write'] but gracefully handle the case where the user's chosen connection is configured read-only — disable the Compose UI and explain why, rather than failing on submit.
// manifest.json — minimal read-only mail consumer { "identifier": "com.you.receiptlog", ... "permissions": [ { "name": "receipts", "type": "use_mail", "description": "Scan messages for receipts.", "connection_names": ["*"], "scopes": ["read"] } ] }
Token & revocation model
Apps authenticate to freezr with an app token obtained at install time. Two facts about this token matter for mail apps in particular:
- Offline tokens persist. Tokens issued via
/acctapi/generateAppPasswordare valid for up to 6 months. An app that holds an offline token can keep making mail calls without the user being signed in — design your storage with that lifetime in mind. (Apps that do not need offline access should not request the token.) - Revoking the permission revokes the token. When the user revokes
use_mailat/account/resources, freezr both (a) flips the permission record togranted: falseand (b) deletes any offline tokens for that(user, app)pair. The next call gets401 "Token not found in database". App code should treat this identically totoken_expired— surface the reauth path, don't fight the failure. - Per-connection revoke is granular. If the user narrows your
connection_namesat/account/resources(e.g. from["*"]to["gmailWork"]), calls against removed connections start returning 403 with no warning. Handle 403 the same way astoken_expired. - System-app permission shortcuts. A small set of system apps (
info.freezr.connections,info.freezr.creator,info.freezr.admin) have auto-granted permissions defined incommon/systemPermissions.json. Third-party apps cannot get on that list — but you should know it exists when reading the perm-load code inmailContext.
Rendering email safely
An email's HTML body is untrusted external content. It can contain <script>, event-handler attributes (onerror, onload, …), javascript: URLs, and CSS that escapes its container. Rendering it carelessly leaks data to attackers and lets remote senders run code in your app's origin. freezr's CSP blocks the worst categories (no inline scripts, no iframes, no <object>/<embed>), but you still need a sanitization step and you should isolate the rendered DOM.
Messaging apps: this section is mail-only — but the threat model still applies. Message text from freezr.connections.messaging is provider markup (Slack mrkdwn) delivered as plain text, never HTML, so none of the sanitizer machinery below is needed. It is still untrusted sender content: render it with textContent (never innerHTML), and resolve <@U123> mentions / <#C123> channel references via getUsers before display rather than regex-rewriting them into markup.
Threat model — read this once. The threat actor for an email body is the sender, not the app author. The sender is an arbitrary third party; the app author is trusted by the user (they installed the app). This is a different model from the rest of freezr's app-side security, and means you don't get to rely on app-installation review to keep dangerous content out — every message body has to be treated as hostile.
Free defenses you inherit from freezr's CSP
freezr's default Content-Security-Policy (set by the platform; see adapters/rendering/pageLoader.mjs) neutralises a large class of attacks before your app does anything. As long as your manifest doesn't request the matching capability permissions (external_scripts, unsafe_eval, etc.), the following cannot execute in a freezr mail viewer:
<script>...</script>blocks —script-src 'self'with no'unsafe-inline'.<img onerror=...>,<svg onload=...>,<body onclick=...>and every other inline event handler.- Inline
javascript:URLs in<a href>/ form actions. <iframe>,<object>,<embed>— blocked byframe-src 'none'/object-src 'none'. (This is why we use Shadow DOM rather than sandbox iframes for isolation.)- Cross-origin script loads — no external script hosts allowed without
external_scripts. - Cookies scoped to other apps — path-scoped cookies mean even a script that did execute can only read tokens for the same path prefix (e.g.
/connections), not the account token.
Residual risks the CSP does NOT cover — sanitize for these
Some attack categories survive the CSP and have to be caught by your sanitizer or by careful rendering. The sanitizer below covers all of them; if you swap in DOMPurify, confirm its config covers them too.
- Visual phishing. A
<a href="https://attacker.example/...">Click here to verify your account</a>looks legitimate inline. CSP doesn't stop the navigation. Rewriting every link totarget="_blank" rel="noopener noreferrer"(as the sample does) at least keeps the user's session in the mail tab. - Page hijack via
<meta http-equiv="refresh">or<base href>. A<meta refresh>in an email body can redirect the entire page; a<base href>can silently rewrite where every relative URL resolves. The sanitizer strips both. - Layout overlay / UI spoofing.
<div style="position:fixed; top:0; left:0; width:100%; height:100%; background:white; z-index:9999">Sign in</div>can cover your entire app with attacker-controlled content. Shadow DOM contains this — content inside a shadow root cannot reach the parent document — which is the main reason to use shadow rendering rather than appending sanitized HTML directly into your app's DOM. - Tracking pixels.
<img src="https://attacker.example/pixel?to=victim@host">reveals to the sender that the user opened the email plus their IP / User-Agent. This is a privacy issue, not a code-execution issue — CSP allows images from any source so the loader runs. Apps that want to block this can rewrite or strip<img src>values pointing to non-allowlisted domains. - Form submission.
<form action="https://attacker.example">can submit to anywhere. CSP'sform-action 'self'only restricts forms whose action begins as same-origin and then redirects; an external action URL is still attempted. Strip<form>outright unless you really want it. - Untrusted attachment
mimeType. ThemimeTypeon each attachment row comes from the sender's MIME headers — they can lie. ThegetAttachmentroute honors what you pass forContent-Type, so a malicious sender combined with an app that trustsatt.mimeTypeblindly can get the browser to render bytes as the wrong type. If correctness matters (e.g. you only want to run a PDF parser on real PDFs), sniff the buffer's first bytes (PDF starts%PDF-, JPEGFF D8 FF, PNG89 50 4E 47) before handling.
The pattern used by the built-in mail client:
- Sanitize first. Use
DOMParserto parse the HTML in an inert document, then walk the tree and remove dangerous elements (script,iframe,object,embed,link,meta,base,frame,frameset) and attributes (everything beginning withon, plus anyhref/src/poster/formaction/xlink:hrefwhose value starts withjavascript:,vbscript:, ordata:text/html). Forstyleattributes, stripexpression(,behavior:,@import. - Isolate via Shadow DOM. Attach a shadow root to a host element and inject the sanitized HTML there. Shadow DOM scopes
<style>tags inside the body so a sender's CSS can't restyle your app. (Iframes would also isolate, but the freezr CSP blocks them.) - Rewrite links. After injection, set
target="_blank" rel="noopener noreferrer"on every<a>so clicks don't navigate the app away and the opened tab can't reach back viawindow.opener. - Plain text fallback. When only
bodyTextis present, render withtextContentinside a<pre>withwhite-space: pre-wrap. NeverinnerHTMLa plain-text body.
// minimal sanitizer + shadow-root rendering function renderBody (host, m) { if (m.bodyHtml) { const shadow = host.attachShadow({ mode: 'open' }) shadow.innerHTML = '<style>a{color:#2563eb} img{max-width:100%}</style>' + sanitize(m.bodyHtml) shadow.querySelectorAll('a[href]').forEach(a => { a.setAttribute('target', '_blank') a.setAttribute('rel', 'noopener noreferrer') }) } else if (m.bodyText) { const pre = document.createElement('pre') pre.style.whiteSpace = 'pre-wrap' pre.textContent = m.bodyText host.appendChild(pre) } } function sanitize (html) { const doc = new DOMParser().parseFromString(html, 'text/html') const kill = ['script','iframe','object','embed', 'link','meta','base','frame','frameset'] kill.forEach(t => doc.querySelectorAll(t).forEach(n => n.remove())) doc.querySelectorAll('*').forEach(el => { [...el.attributes].forEach(a => { const n = a.name.toLowerCase(), v = String(a.value).toLowerCase().trim() if (n.startsWith('on')) el.removeAttribute(a.name) if ((n === 'href' || n === 'src' || n === 'poster' || n === 'formaction' || n === 'xlink:href') && (v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:text/html'))) el.removeAttribute(a.name) }) }) return doc.body ? doc.body.innerHTML : '' }
For production-grade sanitization, swap the inline function above for DOMPurify — it handles a larger range of obscure escape vectors. The inline version above is the minimum bar.
Incremental sync
getNewer uses each provider's native delta API (Gmail history.list, Graph /messages/delta) to return only what changed since the cursor you saved. It is the right primitive for any app that periodically re-checks a mailbox — vastly cheaper than re-paging through listMessages.
Messaging syncs on the same principle but per conversation: messaging.getNewer keeps one cursor token per conversation. Because providers like Slack have no cross-conversation delta API, polling every conversation each cycle burns the workspace's shared rate budget — instead, where the server has messaging sockets enabled, call messaging.getChanges({ since }) first to learn which conversations changed (and which old messages were edited or deleted, which getNewer can never tell you), then getNewer only those. Check freezr.utils.ping() for the messaging_live capability and honor complete: false with a wider sweep — the full pattern is in Messaging (connections).
- Persist
nextTokenper connection. Most apps store it in their own data store (e.g. a freezr collection keyed byconnectionName). Don't store it inlocalStorageunless your app is single-device — the cursor needs to travel with the dataset, not the browser. - Seed before reading. The first call with no
lastTokenreturnschanges: []+ a usablenextToken. Save the token; you'll get real deltas next time. The built-in mail client seeds automatically on page load. - Always handle
expired: true. Provider delta windows have hard limits — Gmail invalidates after ~7 days, Graph keeps deltas longer but not forever. Treatexpiredas "do a fulllistMessagesreload and seed a new cursor." - The Gmail history stream is mailbox-wide. If your app shows one folder at a time, filter the returned
messageAddedevents by checking whether the new message'slabelsarray contains your current folder id.messageDeleted/labelAdded/labelRemovedevents should usually be applied regardless of folder, so your local view stays consistent. - Cron is on the roadmap, not yet shipped. Until freezr cron lands, "incremental sync" runs only when your app's page is open and the user (or your code) calls
getNewer. Don't design for background fetching yet.
Sending
sendMessage constructs an RFC 822 MIME message server-side and hands it to the provider. Bodies pass through as supplied — text/HTML you write is the text/HTML the recipient sees — so do your own escaping when interpolating user data into bodyHtml.
- Plain text is enough to start. Most apps don't need
bodyHtml. Gmail rendersbodyTextwell; recipients see normal-looking mail. - Attachments are inline base64. Read browser File objects via
FileReader.readAsDataURLand slice off thedata:<mime>;base64,prefix to getcontentBase64. Keep the total payload under ~20 MB — provider send limits land somewhere between 25–35 MB and you pay base64 overhead on top. - Threading needs
threadId. Replies that come fromgetMessagealready carry the parent'sthreadId; pass it through tosendMessage. For cross-client threading (Outlook, Apple Mail) also setinReplyToto the parent's RFC 822Message-IDheader — but be aware the unifiedmessageshape doesn't surface that today (planned). - Check write permission up front. Before opening a compose UI, verify the chosen connection has
access.mail === 'readwrite'fromlistAccounts()AND your granted permission included'write'. If either fails, grey out Compose and explain — never let the user write a long message only to have it bounce on send.
Attachments
getAttachment returns a Blob (or ArrayBuffer with responseType: 'arrayBuffer'). The freezr CSP blocks <iframe>, <embed>, and <object>, so the cleanest way to surface a PDF, image, or any binary in the browser is to open a blob URL in a new tab:
const blob = await freezr.connections.mail.getAttachment({ connectionName, messageId, attachmentId: att.id, filename: att.filename, mimeType: att.mimeType }) const url = URL.createObjectURL(blob) window.open(url, '_blank', 'noopener') // the browser's native PDF viewer renders PDFs; images render inline; // anything else triggers a download.
- Treat
mimeTypeas untrusted. The MIME type on the attachment row comes from the sender's MIME headers — they can lie. If you care about correctness (e.g. running a PDF parser on what claims to be a PDF), sniff the first few bytes of the buffer (PDF starts%PDF-, JPEGFF D8 FF, PNG89 50 4E 47, etc.). - Revoke blob URLs you create. Either keep them in a list and call
URL.revokeObjectURLwhen you navigate away, or use a short timeout after the click — leaking blob URLs holds memory open as long as the tab lives. - Don't render attachment HTML inline. A
.htmlattachment is just an attachment. If you must show it, fetch it as a blob, sanitize, then render in a Shadow DOM as with the body — neverfetch().then(r => r.text()).then(html => container.innerHTML = html).
Common pitfalls
- Token expiry surfaces as a thrown error, not a success response. Wrap every call in a try/catch and use
freezr.connections.mail.handleTokenExpired(err)as the first check in the catch — it returnstrueafter navigating to the connection's reauth URL. Falling through to a generic error toast loses the user. - Gmail labels ≠ folders. A message can carry multiple labels and several "system" labels are non-exclusive (
STARRED,IMPORTANT, category tags).moveMessageon Gmail is an approximation: the connector adds the target label and removesINBOXwhen the target is anything else. Don't assume "I moved it" means "it left every other folder." - The Gmail search
qis provider-specific. UsesearchMessageswith structured params if you want code that will work unchanged when the Microsoft Graph / IMAP connectors land. The connector translates the same shape into each provider's native query language. - Don't store messages just to "speed things up." The unified API is fast and the provider is the source of truth. Local storage is a correctness liability — sync errors, deletes you missed, label changes you didn't observe. Cache for UI snappiness if you must; never treat your cache as authoritative.
- The
connection_namesfilter is enforced server-side. If a user later revokes access to a specific connection, your calls against that connection start returning 403 — handle it the same way astoken_expired. - Don't call
getMessagein a tight loop. Per-message fetches are real provider hits. If you need bodies for many messages, paginate withlistMessages(which returns metadata cheaply) and fetch the full body only when the user opens one. For attachment manifests without bodies, uselistMessages({ includeAttachments: true })— same row shape, no body decode.
Connecting accounts — OAuth setup for server operators
Users connect Gmail and Microsoft accounts at /connections/new, but that only works if the freezr server holds OAuth credentials for the provider. Google connections can fall back on a freezr-hosted default authenticator; for Microsoft there is no shared default — every server registers its own (free) app with Microsoft Entra. The full walkthrough for both providers — consoles, redirect URIs, scopes, secrets, and troubleshooting — is at setting up mail OAuth. IMAP mailboxes (Yahoo, iCloud, Fastmail…) IMAP mailboxes (Yahoo, iCloud, Fastmail…) skip OAuth entirely and use an app password via Add IMAP Mailbox on /account/resources. Messaging works the same way: Slack connections require the server operator to register a (free) Slack app — the walkthrough, including the optional Socket-Mode setup that powers getChanges live updates, is in the Slack section of the OAuth setup page.
Building React apps for freezr
React runs on freezr as a static bundle — JSX is transpiled at build time, and the output is plain HTML / CSS / ES-module JS that freezr can serve directly. A working end-to-end starter lives at github.com/salmanff/com.salmanff.reacthello.
Constraints
- No inline scripts. CSP blocks them. React's JSX event handlers (
onClick={...}) and module imports are fine; avoid libraries that inject<script>tags at runtime. - No HTML document wrapper. The served
index.htmlis inner-body only (typically<div id="root"></div>). The Vite config below strips the wrapper automatically. - ES modules. Manifest
modulesentries become<script type="module">. Keep build format'es'. - One CSS bundle. Set
build.cssCodeSplit: false— lazy-loaded CSS chunks with hashed names won't be listed in the manifest. - No
<head>ownership. Avoid libraries that assume they controldocument.titleor<meta>tags. - Client-side routing needs a manifest SPA flag not yet in the reference implementation — use hash routing for now.
Vite + React setup
The recommended layout is a single repository where the root is the installable freezr app and a react/ subfolder holds the source:
my-app/ index.html // built: inner-body HTML app.js // built: single ES-module bundle style.css // built: single CSS bundle manifest.json // built: with real filenames react/ // source package.json vite.config.js index.html // dev-only wrapper manifest.json // template src/ main.jsx App.jsx freezrStore.js styles.css
Mount React inside the freezr.initPageScripts callback in production, and immediately in dev (where there is no freezr global):
import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App.jsx' import './styles.css' function mount () { createRoot(document.getElementById('root')) .render(<StrictMode><App /></StrictMode>) } if (typeof window !== 'undefined' && window.freezr) { window.freezr.initPageScripts = mount } else { mount() }
Keep all freezr.* calls behind a thin wrapper module, with a localStorage fallback for dev mode:
// freezrStore.js const hasFreezr = () => typeof window !== 'undefined' && window.freezr && typeof window.freezr.query === 'function' export async function listNotes () { if (hasFreezr()) { return window.freezr.query('notes', {}, { sort: { _date_modified: -1 } }) } return readLocal() }
Vite config & packaging
A small Vite plugin does two things after the standard build: write an inner-body index.html, and rewrite manifest.json with the real built filenames:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { copyFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' function freezrBuild () { let projectRoot, repoRoot, distPath return { name: 'freezr-build', apply: 'build', configResolved (config) { projectRoot = config.root repoRoot = resolve(projectRoot, '..') distPath = resolve(projectRoot, config.build.outDir) }, closeBundle () { const files = readdirSync(distPath) const jsFile = files.find(f => f.endsWith('.js')) const cssFiles = files.filter(f => f.endsWith('.css')) copyFileSync(resolve(distPath, jsFile), resolve(repoRoot, jsFile)) cssFiles.forEach(c => copyFileSync(resolve(distPath, c), resolve(repoRoot, c))) writeFileSync(resolve(repoRoot, 'index.html'), '<div id="root"></div>\n') const manifest = JSON.parse(readFileSync( resolve(projectRoot, 'manifest.json'), 'utf8')) manifest.pages.index.modules = [jsFile] manifest.pages.index.css_files = cssFiles writeFileSync(resolve(repoRoot, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') } } } export default defineConfig({ plugins: [react(), freezrBuild()], build: { outDir: 'dist', emptyOutDir: true, cssCodeSplit: false, rollupOptions: { input: resolve('src/main.jsx'), output: { entryFileNames: 'app.js', assetFileNames: '[name][extname]', format: 'es' } } } })
Add a pack script that builds and zips:
"scripts": { "dev": "vite", "build": "vite build", "pack": "vite build && cd .. && zip -r -X com.you.myapp.zip \ index.html app.js style.css manifest.json -x '*.DS_Store'" }
Then:
- Run
npm run packfrom insidereact/. - Log into your freezr server, open the Account app → Install Existing Apps → Upload.
- Upload the zip. The filename must match the app identifier exactly and files must be at the zip root.
- Your app is available at
/app/<app_id>.