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 -d @permute/sdk # 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 {
// 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
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
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. App-to-business-action calls and anonymous public access are not available in this release.