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)

How it works

Whichever way you create your apps, you should be aware of the way freezr ulls togehter 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.

specs / app manifest

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

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

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.

FieldTypeDescription
page_titlestringrequiredBrowser tab title
html_filestringrequiredInner-body HTML filename
modulesarrayoptionalES-module JS file(s) injected as <script type="module"> with the page nonce
script_filesarrayoptionalNon-module JS file(s) injected as plain <script> tags
css_filesarrayoptionalCSS file(s) linked in the page <head>
initial_queryobjectoptionalWIP. 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.

PermissionGrants
read_allRead all records in another app or user's table (broad scope)
write_allCreate / update records in another app or user's table
write_ownRead al and create records in another app or user's table, and be able to update the records that user/app has created.
share_recordsShare 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_queryWIP allow apps or users to access records based on specific query criteria.
message_recordsSend record-linked messages to other users
upload_pagesCreate 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_framesRelax CSP to allow same-origin and blob: iframes
external_fetchRelax connect-src to whitelisted external domains
external_scriptsAllow external scripts with SRI hashes
unsafe_evalAllow eval() / new Function()
use_llmAllow the app to use your llm API Key to ask your llm to perform any task.
use_serverlessAllow the app to use your serverless API Key to send code to e exeucited on the backl end.
use_3pFunctionInvoke a named local serverless function. This should only be used in development or for highly trusted code. It does actually run on the back end!
specs / client api

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:

NamespacePurpose
freezr.create / read / update / updateFields / delete / queryRecord CRUD
freezr.collection(name)Bound helper for a single table
freezr.publicqueryQuery another user's public records
freezr.upload / getFileUrl / deleteFileFile 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.serverless.*Invoke and manage cloud (AWS Lambda) and local (trusted) functions
freezr.utils.*Helpers: cookie reading, ping, manifest lookup, resource usage, path building
freezr.apiRequestLow-level HTTP helper for custom endpoints
The 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.

fn freezr.create(collection, data, options?) Create a record
Arguments
collection string required
Bare table name (e.g. "notes") or full "com.other.app.items".
data object required
Record fields. _id is assigned server-side unless data_object_id is passed.
options.data_object_id string optional
Force a specific record ID (FEPS path). Certain server /db set ups may require specific formats for ids and restrict this option.
options.upsert boolean optional
Update if exists, otherwise insert (FEPS path).
options.requestee_app, permission_name, owner_id string optional
Write to another app's / another user's table via a granted permission (FEPS path).
// simple create
const res = await freezr.create(
  'notes',
  { title: 'Hello', body: 'First note' }
)
// => { _id: 'abc123', ... }
// upsert with forced id
await freezr.create('notes', data, {
  data_object_id: 'abc123',
  upsert: true
})
fn freezr.read(collection, id, options?) Read a record by id
Arguments
collection string required
id string required
The record's _id.
options.owner_id string optional
Read a record owned by another user (requires a granted share_records / read_records permission).
options.permission_name string optional
Name of the permission under which to access the record.
// own record
const note = await freezr.read('notes', 'abc123')
// another user's shared record
await freezr.read('notes', 'abc123', {
  owner_id: 'alice',
  permission_name: 'shared_notes'
})
fn freezr.update(collection, id, data, options?) Replace a record (all fields)
Arguments
id string required
data object required
New record contents. Existing fields are replaced.
// replace
await freezr.update('notes', 'abc123', {
  title: 'Updated',
  body:  'New body'
})
fn freezr.updateFields(collection, idOrQuery, fields, options?) Partial update — change only some fields
Arguments
idOrQuery string | object required
Record _id for single-record update, or a query object to update all matching records.
fields object required
Fields to set. Unspecified fields are preserved.
// single record
await freezr.updateFields('notes',
  'abc123',
  { done: true }
)
// bulk
await freezr.updateFields('notes',
  { archived: false },
  { archived: true }
)
fn freezr.delete(collection, idOrQuery, options?) Delete a single record or all matching records
Arguments
idOrQuery string | object required
Record id, or a query object for bulk delete (routed to FEPS).
// single
await freezr.delete('notes', 'abc123')
// bulk
await freezr.delete('notes',
  { archived: true }
)
fn freezr.collection(name) Bound helper for a single table
Returns
An object with create, read, query, update, updateFields, delete pre-bound to name — convenient when a component only touches one table.
// usage
const notes = freezr.collection('notes')
await notes.create({ title: 'Hi' })
const all = await notes.query({})

Querying records

fn freezr.query(collection, query?, options?) Query records with a MongoDB-style filter
Arguments
query object optional
MongoDB-style filter. Dangerous operators ($where, $function, $accumulator, $expr) are rejected server-side.
options.count number optional
Max records to return.
options.skip number optional
Records to skip (for pagination).
options.sort object optional
e.g. { _date_modified: -1 }.
options.requestee_app, owner_id, permission_name string optional
Query another app's or user's records under a granted permission.
// own records
const rows = await freezr.query(
  'notes',
  { archived: false },
  { sort: { _date_modified: -1 },
    count: 20 }
)
// public query
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, and the file body is addressed by a URL the browser fetches directly.

fn freezr.upload(file, options?) Upload a File / Blob
Arguments
file File | Blob required
A File object (from <input type="file">) or a Blob.
options.doNotOverWrite boolean optional
If a file with the same name exists, error instead of replacing it.
// upload
const file = evt.target.files[0]
const res = await freezr.upload(file)
// => { _id, publicid, filename, size, ... }
fn freezr.getFileUrl(fileId, options?) Build a browser-fetchable URL for a stored file
Arguments
fileId string required
The file's id or stored path.
// usage
// <img src={ freezr.getFileUrl(id) } />
// => /feps/userfiles/<app>/<user>/<id>
fn freezr.deleteFile(fileId, options?) Delete a file and its metadata
Note
Equivalent to freezr.delete('files', fileId). The underlying storage is cleaned up by the file-system adapter.
// delete
await freezr.deleteFile('img_001')

Permissions & sharing

fn freezr.perms.getAppPermissions(options?) List the permissions the user has granted this app
Returns
Array of { name, granted, description, ... }. Also available: freezr.perms.isGranted(name) for a simple boolean check.
// usage
const perms = await freezr.perms.getAppPermissions()
if (await freezr.perms.isGranted('share_records')) {
  // show share UI
}
fn freezr.perms.shareRecords(idOrQuery, options) Share record(s) with other users
Arguments
idOrQuery string | array | object required
Default: the original record _id (string), or an array of _ids. Pass { publicid: '...' } only when the publicid is the only thing you have (e.g. orphan cleanup). For that case prefer freezr.perms.unshareByPublicId(...).
options.grantees array required
Array of user ids to grant access to. Use ['_public'] for public.
options.name string required
Permission name declared in the app manifest.
options.action string optional
'grant' (default) or 'deny'.
// grant to a user
await freezr.perms.shareRecords('abc123', {
  grantees: ['bob'],
  name: 'shared_notes',
  action: 'grant'
})
// unshare (default = by record _id)
await freezr.perms.shareRecords('abc123', {
  grantees: ['_public'],
  name: 'publish_posts',
  table_id: 'com.example.myapp.posts',
  action: 'deny'
})
// publish a file (returns the share response)
await freezr.perms.shareFilePublicly('my/file/path.html', { name: 'publish_site', grant: true })
// publicid is `@<userId>/<app>.files/<fileId>` (or your custom `publicid` option)
// public URL = serverUrl/<publicid> — do NOT add extra path segments
// unshare when you only have the publicid (orphan-safe)
await freezr.perms.unshareByPublicId(publicid, {
  name: 'publish_site',
  table_id: 'com.example.myapp.files',
  grantees: ['_public'],
  forcePublicIdCleanup: true  // delete public record even if source is gone
})

Messages

Messages are small, record-linked notifications delivered across users on the same server (or across federated servers). Common uses: share invitations, activity notifications.

fn freezr.messages.send(message) Send a record-linked message
message
recipient_id | recipients string | array required
table_id string required
Full <app_id>.<table> containing the record.
record_id string required
sharing_permission | messaging_permission string required
contact_permission string required
// send
await freezr.messages.send({
  recipient_id: 'bob',
  table_id: 'com.you.notes.entries',
  record_id: 'abc123',
  sharing_permission: 'shared_notes',
  contact_permission: 'contact_users'
})
// inbox
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.

fn freezr.llm.ask(prompt, options?) Send a prompt; stream the response back
Arguments
prompt string | array required
Text prompt, or an array of { role, content } for a conversation history.
options.context string optional
System message (persona / instructions).
options.provider string optional
'Claude' or 'ChatGPT'.
options.family, model, max_tokens mixed optional
Model family ('sonnet', 'opus', 'mini'...), specific model id, token cap.
options.files File | File[] optional
Attached files (images, PDFs, etc).
options.streamBack, onDelta, onThinking mixed optional
When streamBack: true, chunks are delivered via the callbacks as they arrive.
options.thinking boolean | object optional
Enable extended reasoning (Claude) or reasoning effort (ChatGPT o-series).
options.responseType string optional
'json' to auto-parse a JSON response. Incompatible with streamBack.
// one-shot
const res = await freezr.llm.ask('Summarise', {
  context: 'You are a concise assistant',
  family: 'sonnet'
})
console.log(res.response, res.meta.tokensUsed)
// streaming
await freezr.llm.ask(prompt, {
  streamBack: true,
  onDelta: (t) => appendToUi(t)
})
// image
const img = await freezr.llm.generateImage(
  'A cat in a wizard hat, watercolor',
  { size: '1024x1024' }
)
// img.b64Data is a PNG base64 string

Serverless

Apps can invoke two kinds of server-side compute: cloud functions (AWS Lambda instances owned by the user) and local functions (Node modules loaded from users_3Pfunctions/, admin-installed). Both are addressed through the same freezr.serverless namespace and share a validation model: function names must match [a-zA-Z0-9._-]+ and the caller must hold a use_3pFunction permission.

FunctionEffect
freezr.serverless.invokeCloud(opts)Invoke an already-deployed cloud function
freezr.serverless.invokeLocal(opts)Invoke a local (trusted) function by name
freezr.serverless.createInvokeCloud(opts)Create-if-missing and invoke
freezr.serverless.upsertCloud / updateCloud / deleteCloudLifecycle management for cloud functions
freezr.serverless.roleCreateCloud / deleteRoleProvision IAM role for a new function
freezr.serverless.upsertLocal / deleteLocalAdmin install / remove local functions (from a zip)
freezr.serverless.getAllLocalFunctions()List installed local functions
// invoke a cloud function
const res = await freezr.serverless.invokeCloud({
  function_name: 'com.you.myapp-summariser',
  payload: { text: '...' }
})
// invoke a local function with a file attachment
await freezr.serverless.invokeLocal({
  function_name: 'zipBuilder',
  file: someBlob,
  metadata: { target: 'com.you.hello' }
})
specs / microservices

Microservices

freezr apps are generally client-side. If an app needs server-side compute — heavy processing, cron jobs (coming), or running other node modules, in untrusted build steps for example. There are two patterns, both reached via freezr.serverless.*.

The code itself would be packaged as a standard zip file with the serverless code in there. The serverless API also allows for limited reading of databases o populate the data to be transmitted to the server, and also receive data back.

Cloud (serverless) functions

Deployed to the user's own cloud account - AWS Lambda is the only chopice right now. The user's cloud credentials are stored in their freezr account profile. and the use_serverless permission allows the app access to the credentials.

  • Lifecycle: roleCreateCloud provisions the IAM role; upsertCloud / updateCloud / deleteCloud deploy and remove code; createInvokeCloud does upsert-and-invoke in one call.
  • Invocation: invokeCloud calls the Lambda with a JSON payload (or multipart form including the file).

Local (trusted) functions

Installed by the server admin from a zip uploaded into users_3Pfunctions/. Each function is a Node ES module whose default export is an async handler (payload, context) => result. The module runs in-process on the freezr server and has full access to the server's Node environment, so the admin trust model is analogous to installing an npm package.

  • Install / update / delete: admin-only via freezr.serverless.upsertLocal / deleteLocal.
  • Invocation: any user via freezr.serverless.invokeLocal, gated by the app's declared use_3pFunction permission.
  • Function names and permission names are restricted to [a-zA-Z0-9._-]+. Zip extraction applies zip-slip protection.
specs / react apps

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.html is inner-body only (typically <div id="root"></div>). The Vite config below strips the wrapper automatically.
  • ES modules. Manifest modules entries 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 control document.title or <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:

  1. Run npm run pack from inside react/.
  2. Log into your freezr server, open the Account app → Install Existing AppsUpload.
  3. Upload the zip. The filename must match the app identifier exactly and files must be at the zip root.
  4. Your app is available at /app/<app_id>.
A forthcoming in-browser React builder will let developers write JSX in the freezr creator app, click Build, and have a serverless instance return the packaged zip for direct install. Until then, the local Vite build described here is the supported path — and the resulting zip is the same artifact the builder will produce.