Workflow
- 1Prepare your project and API key. Creating an app requires permission to write apps in the workspace; updating an existing app can use a key with read/write access to just that app. Keep the key in your deployment environment, never in browser code.
- 2Build the frontend into dist with relative asset URLs. Keep source, package manifests, and the dependency lockfile in your own project. Permute stores the compiled build, and does not run your build tools.
- 3Create an app or select an existing app ID. Creation provisions a URL and publishes a starter page. The script waits for hosting to become live before uploading your build.
- 4Upload, save, then publish. apps.uploadFiles stores immutable files without changing a draft. apps.saveSite verifies the complete file list and saves a draft revision. revisions.publish makes that draft live; the script below performs all three steps.
- 5Open the printed URL and sign in with Permute. Visitors need app read permission. To update the site, rebuild and rerun with the same app ID; to roll back, use an earlier revision ID as shown below.
Project setup
# For a new React project; skip creation if you already have a frontend. bun create vite operations-portal --template react-ts cd operations-portal bun install bun add @permute/sdk@^0.6.0 # Edit src, then build the complete site with relative asset URLs. bun run build --base=./
Complete example
// Save as deploy.ts in your frontend project. Running it publishes the build.
import { PermuteClient, type AppUploadFile, type ResourceVersionState } from '@permute/sdk';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
function required(name: string): string {
const value = process.env[name]?.trim();
if (!value) throw new Error('Set ' + name + ' before running this script');
return value;
}
const client = new PermuteClient({
apiKey: required('PERMUTE_API_KEY'),
baseUrl: process.env.PERMUTE_API_BASE_URL, // Defaults to https://api.permute.ai.
}).withWorkspace(required('PERMUTE_WORKSPACE_ID'));
const appId = process.env.PERMUTE_APP_ID;
const rollbackRevisionId = process.env.PERMUTE_ROLLBACK_REVISION_ID;
if (rollbackRevisionId && !appId) throw new Error('Set PERMUTE_APP_ID when rolling back');
let app = appId
? await client.apps.get(appId)
: await client.apps.create({ name: 'Operations portal', key: 'operations-portal', description: '' });
console.log('App ID:', app.id);
const deadline = Date.now() + 10 * 60_000;
while (app.deployment?.status !== 'live') {
if (!app.deployment) throw new Error('This app has no hosting deployment');
if (['failed', 'deleting', 'deleted'].includes(app.deployment.status)) {
throw new Error(app.deployment.error ?? 'App cannot be published: ' + app.deployment.status);
}
if (Date.now() >= deadline) throw new Error('Provisioning timed out; inspect this app before retrying');
await new Promise((resolve) => setTimeout(resolve, 2000));
app = await client.apps.get(app.id);
}
async function readBuild(directory: string, prefix = ''): Promise<AppUploadFile[]> {
const files: AppUploadFile[] = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = prefix + entry.name;
if (entry.isDirectory()) files.push(...await readBuild(join(directory, entry.name), path + '/'));
else if (entry.isFile()) files.push({ path, data: await readFile(join(directory, entry.name)) });
else throw new Error('Build contains a symbolic link or unsupported entry: ' + path);
}
return files;
}
let ready: ResourceVersionState;
if (rollbackRevisionId) {
const current = await client.revisions.list(app.id);
ready = await client.revisions.restore(app.id, rollbackRevisionId, { expectedVersion: current.version });
} else {
const current = await client.revisions.list(app.id);
if (current.draft?.revisionId !== current.published?.revisionId) {
throw new Error('Review the existing unpublished draft before replacing it');
}
// 1. Upload bytes. This does not change the draft or the live app.
const files = await client.apps.uploadFiles(app.id, await readBuild('./dist'));
// 2. Save the COMPLETE file list as a draft, preserving app settings.
const saved = await client.apps.saveSite(app.id, {
site: { files, spa: true },
expectedDraft: app.draft,
});
ready = await client.revisions.list(app.id);
if (ready.draft?.revisionId !== saved.draft.revisionId || ready.draft.sequence !== saved.draft.sequence) {
throw new Error('Another author changed the draft; review before publishing');
}
}
// 3. Publish the exact draft we saved or restored. Content updates need no hosting rollout.
console.log('Previous live revision:', ready.published?.revisionId);
const published = await client.revisions.publish(app.id, { expectedVersion: ready.version });
console.log('Live app:', app.deployment.url);
console.log('Published revision:', published.published?.revisionId);Run the deployment
Save the complete example as deploy.ts and run it from the project root so it can find dist. Supply PERMUTE_API_KEY through your shell or deployment secret store. Set PERMUTE_API_BASE_URL if you are using a different API environment.
For a new app, choose its name and a stable creation key in the script. Reuse that key when retrying creation, and keep the printed app ID for later updates. A different new app needs a different creation key.
# PERMUTE_API_KEY must already be set in your shell. export PERMUTE_WORKSPACE_ID=wksp_... # First deployment: creates an app, uploads dist, saves a draft, and publishes. bun run deploy.ts # Later deployments: rebuild and update the same app. export PERMUTE_APP_ID=appl_... bun run build --base=./ bun run deploy.ts
Directory deployment helper (SDK 0.6.0)
SDK 0.5.0 introduced deployApp from @permute/sdk/deploy for Node and Bun. Use SDK 0.6.0 for the grant-based app API shown here. The helper validates the complete build before uploading, saves a draft, and publishes with compare-and-set checks; it does not build or provision the app.
By default the draft must already be published. To replace an unpublished draft, review it and pass its exact expectedDraft. Omitted actions preserve the current bindings (an empty array clears them); supplied actions must name exact published revisions. Unchanged files, SPA mode, and supplied bindings return changed: false without uploads or writes; the helper rechecks refs first. A reviewed unpublished draft still publishes its settings changes. The helper stops on concurrent changes and never retries mutations.
After a failed or lost response, inspect app history before retrying: publication may have completed, or the saved draft may need review. Use individual upload/save calls when you want to review a draft before publication.
import { deployApp } from '@permute/sdk/deploy';
// client and app come from the provisioning script above.
const deployed = await deployApp(client, { appId: app.id, directory: './dist' });
console.log(deployed.url, deployed.revisionId, deployed.changed);What happens to files and versions
Uploading the same contents reuses the existing file. Changed contents at the same path are stored separately, and the new draft points that path to the new contents. Previous versions keep their original files.
Every save replaces the complete file list (the manifest), so include unchanged files too. A file omitted from the new manifest disappears from the live app when you publish. Uploading or saving alone never changes the live site.
Apps use the shared resource versioning system. App-specific save calls validate the files and advance the draft; revisions.list, revisions.get, revisions.restore, and revisions.publish provide history, revision reads, rollback, and publication. To review a draft before release, stop after saving and publish it separately.
expectedDraft and expectedVersion reject concurrent changes. On a 409 conflict, read the current draft and reconcile your build before trying again. Replacing an existing unpublished draft is an intentional edit; review it before saving.
Roll back a published app
Use the previous live revision printed by the script, or choose a revision from client.revisions.list(appId). Run the command below with the same API key and workspace. It restores that revision into a new draft and publishes it without uploading files.
Restore recovers the complete app definition, including its name, description, and files. Restoring alone leaves the live site unchanged. Previously published assets remain readable under current app permissions so pages already open can finish loading; unpublished drafts are not served.
PERMUTE_APP_ID=appl_... PERMUTE_ROLLBACK_REVISION_ID=rvsn_... bun run deploy.ts
Pin actions to an app release
Use SDK 0.6.0 or later. Create and publish a business action with the business-actions guide first. Add action revision pins to the apps.saveSite request in deploy.ts, as shown below. The files and app variables come from that deployment script; read the action definition to select its published revision.
Saving and publishing require invocation permission on each selected action. Omitting actions preserves the current bindings; passing [] clears them. Publish the saved app draft with the same revision check used in deploy.ts.
Before publishing, an app admin must approve resource grants on the app detail page or through the grants API. Approval also requires admin permission on each target. Grant Read for query sources and the action’s required invocation level for actions. Existing apps need this approval too; bindings alone no longer authorize app API calls.
Each app revision pins its action revisions. Publishing a new action does not silently change an existing app; update the binding and publish the app again to adopt it. Query access follows current app grants and user permissions; source grant changes take effect without republishing. SQL filters in browser code are not a row-level security boundary.
const actionId = required('PERMUTE_ACTION_ID');
const action = await client.actions.definition(actionId); // Requires action admin.
if (!action.published) throw new Error('Publish the action first');
const saved = await client.apps.saveSite(app.id, {
site: { files, spa: true },
expectedDraft: app.draft,
actions: [{ actionId, revisionId: action.published.revisionId }],
});Approve and revoke app access
App grants apply to all published revisions, including pages already open. Revoking a grant blocks subsequent requests without publishing a new site. Saving or restoring a revision does not recreate grants. To revoke, send the same PUT request with level: null; app admin permission is sufficient.
Each request must fit the signed-in user’s permissions and the app’s current grants. Action calls also require a revision pin in the served app release; queries do not require a source binding. A grant to invoke an action does not grant raw access to its database or connectors. The action retains its separately approved execution scope (the caller only needs invocation permission).
# Use an app and target administrator’s API key outside the browser.
curl -X PUT "https://api.permute.ai/v1/apps/$PERMUTE_APP_ID/grants/$PERMUTE_DATABASE_ID" \
-H "Authorization: Bearer $PERMUTE_API_KEY" \
-H "X-Workspace-Id: $PERMUTE_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"level":"read"}'
# Use the action’s required invocation level (write in this example).
curl -X PUT "https://api.permute.ai/v1/apps/$PERMUTE_APP_ID/grants/$PERMUTE_ACTION_ID" \
-H "Authorization: Bearer $PERMUTE_API_KEY" \
-H "X-Workspace-Id: $PERMUTE_WORKSPACE_ID" \
-H "Content-Type: application/json" \
-d '{"level":"write"}'Read the current user, query and invoke from the frontend
Bundle this code into your frontend. PermuteAppClient uses the visitor’s existing Permute sign-in session and the page’s revision-specific base URL. Keep the deployment API key out of browser code. Use a database with an approved app read grant and an action pinned to the app revision.
Call app.me() to display the current signed-in user’s id, name and email. It uses the app session, checks current membership and app read access, and returns no organization or workspace directory. Password demos have no user identity and return 403. The HTTP equivalent is GET /v1/me on the app hostname, with its session cookie.
Queries require current source read permission for both the signed-in user and the app. Action calls require the action’s declared invocation permission and execute its delegated scope. Reuse the exact requestId and input after an uncertain response; the browser client does not retry writes automatically. A 401 means the visitor needs to sign in again.
import { PermuteAppClient } from '@permute/sdk/app';
const app = new PermuteAppClient();
const user = await app.me();
document.querySelector('#user-name')!.textContent = user.name;
const rows = await app.query({
sourceIds: ['dbas_...'],
sql: 'SELECT id, name FROM app.customers',
limit: 100,
});
console.log(rows.rows);
// Retain this request until its outcome is known; retries reuse the same object.
const request = { requestId: crypto.randomUUID(), input: { id: crypto.randomUUID(), name: 'Acme' } };
const receipt = await app.actions.invoke('actn_...', request);
console.log(receipt.output);Recover a write after sign-in or an app update
Before sending a write, retain its original document.baseURI, actionId, requestId, and input in storage appropriate to the input’s sensitivity. Never store credentials or session cookies. The SDK does not persist or automatically retry requests.
After sign-in or a newer app release, resume as the same user with the original base URL and request (not the new page’s defaults). This preserves the action and app revision selected for the original attempt. Old revisions still enforce current permissions.
A later 401, 403, or 409 does not establish that an earlier attempt failed to commit. Keep an uncertain request until its outcome is reconciled; do not clear it, alter its input, or create a replacement ID just because a retry failed. Resolve an earlier uncertain write before submitting edited input as a new operation.
// pending was retained before the original send, without credentials.
const originalApp = new PermuteAppClient({ baseUrl: pending.baseUrl });
const receipt = await originalApp.actions.invoke(pending.actionId, pending.request);Choose visitor access
Apps default to Permute sign-in. Existing workspace membership, app permissions, user permissions and app grants continue to govern access. For a demo with mocked data, an app admin can select Shared password (demo) under App access, or use the access API below.
The password gates every frontend file, including JavaScript and direct asset URLs. Anyone admitted can inspect downloaded code and files from previously published releases. Use a separate demo app if older releases contain sensitive content. Password demos cannot query Permute data or invoke Business Actions, even with existing app grants.
Passwords must have 8 to 256 characters. The server stores a salted hash and limits password submissions to 20 per minute per app. Sessions last up to eight hours. Saving access settings applies immediately and signs out existing visitors; it does not create or publish a site revision. Restoring a site does not restore access settings. Password-free public access is unavailable.
// Requires an SDK build with apps.access/setAccess (not included in 0.6.0).
const access = await client.apps.access(appId);
await client.apps.setAccess(appId, {
mode: 'password',
password: required('DEMO_PASSWORD'),
expectedVersion: access.version,
});
// To require Permute sign-in again, read the latest access version and send
// { mode: 'permute', expectedVersion: latest.version }.# App admin API key; keep both credentials out of browser bundles.
curl "https://api.permute.ai/v1/apps/$PERMUTE_APP_ID/access" \
-H "Authorization: Bearer $PERMUTE_API_KEY" \
-H "X-Workspace-Id: $PERMUTE_WORKSPACE_ID"
# PUT the same URL with JSON {"mode":"password","password":"...","expectedVersion":0}.
# Use the version returned by GET. Never commit the password to source control.Set the app favicon
In App details → Appearance, choose a square PNG up to 256 KiB, save the draft, then publish. The favicon appears in browser tabs and on both sign-in methods, independently of custom login styling. It overrides icons in the compiled site. Set favicon to null to remove the override; the site’s own icon then applies.
The favicon uses the existing app upload API and version history (no separate storage or deployment). Only the current published favicon is public before sign-in. Draft icons require app read access; historical icons are served only to authenticated visitors of that published release. Each app has its own icon; there is no organization default.
import { readFile } from 'node:fs/promises';
const current = await client.apps.get(appId);
const [favicon] = await client.apps.uploadFiles(appId, [
{ path: 'branding/favicon.png', data: await readFile('./favicon.png') },
]);
const saved = await client.apps.update(appId, { favicon, expectedDraft: current.draft });
const state = await client.revisions.list(appId);
if (state.draft?.revisionId !== saved.draft.revisionId || state.draft.sequence !== saved.draft.sequence) {
throw new Error('Draft changed; review before publishing');
}
await client.revisions.publish(appId, { expectedVersion: state.version });Build and hosting requirements
Upload the complete dist directory, including index.html. The limits are 200 files, 4 MiB per file, and 50 MiB per build. The SDK hashes each file and uploads it through a signed URL; saving verifies the checksum and size before accepting the manifest.
Use relative URLs for bundled and public assets. Permute inserts a revision-specific base element into HTML so relative files stay on the same version. Root-relative asset paths bypass that pin. For a runtime data file, fetch(new URL("data/example.json", document.baseURI)) stays on the page’s revision.
The example sets spa: true, which serves index.html for extensionless HTML navigation. Missing asset files still return 404. Use spa: false when the build contains separate HTML pages and does not need client-side routing fallback.
Hosting supports static HTML, CSS, JavaScript, and assets. Inline scripts, external scripts and API requests, workers, and server-side rendering are unavailable. There is no hosted build service or app source editor; keep source and lockfiles in your own project. With Permute sign-in, the browser SDK supports governed queries and published business actions through the app session. Shared-password demos serve frontend files only; password-free public access is unavailable.
Custom app addresses
Organization admins connect a dedicated namespace, such as apps.example.com, in Organization Settings → App domains. Add the four displayed NS records at your DNS provider for that subdomain (often entered as apps). Keep your main domain’s nameservers unchanged. Permute manages app routing and HTTPS certificate validation in the delegated zone. Keep the NS records while using the namespace; setup is asynchronous, so wait for Ready.
Once the namespace is ready, app admins can choose a name under App details → App URL. The SDK equivalent is client.apps.address(appId), followed by client.apps.setAddress(appId, { address: { namespaceId, label: "crm" }, expectedUrl: current.address?.url ?? null }). Use a namespace ID returned by the address read. Set address to null to remove the custom address. These methods require an SDK release containing apps.address and apps.setAddress; the REST routes are GET and PUT /v1/apps/{applicationId}/address.
An address assignment takes effect without a content publication or CloudFront rollout. Existing generated Permute URLs remain available. Each hostname has its own sign-in session; app permissions, grants, and password-demo restrictions apply on every address.
Removing the organization namespace immediately disconnects all its custom app addresses, then starts cloud cleanup. Remove its NS records from your DNS provider to allow final zone deletion after cached delegation expires. Reconnecting the same namespace reuses its retained zone and nameservers while available, rechecks delegation and provisions HTTPS again. Check the displayed NS records after reconnecting and reassign app addresses; previous assignments are not restored.
This release supports one namespace per organization in each environment and one custom address per app. Each app occupies one subdomain level, such as crm.apps.example.com. Individual domain aliases, routing multiple apps under URL paths, and custom domains for the main Permute platform are not included. Namespace setup requires a signed-in organization admin; API keys can assign app addresses but cannot manage organization DNS ownership.