Apps
Create, deploy, and control Beeuni Apps from the SDK and from React.
Apps are project-scoped deployments with one stable URL each, immutable
releases, machines per process group, and persistent volumes. Branch on app.capabilities. This page covers the SDK surface: the
apps facade on a project handle, the artifact, release, machine, and volume
calls, sign-in tokens, the exported types, and the React hooks.
For the model, the release config, the source kinds, the CLI, the stable URL, and cold-wake behavior, read Apps.
const apps = beeuni.project(projectId).apps;
The apps facade
| Method | Wraps | What it does |
|---|---|---|
apps.list() |
GET /projects/:pid/apps |
Lists the Apps the caller may open (a project manager sees all) |
apps.create(input) |
POST …/apps |
Creates an App and assigns its stable URL |
apps.get(appId) |
GET …/apps/:id |
Reads one App |
apps.update(appId, input) |
PATCH …/apps/:id |
Renames it or changes machine, idle timeout, run mode (always_on), uses, or labels. Run mode applies within 5 minutes; a machine change applies to the next release |
apps.remove(appId) |
DELETE …/apps/:id |
Deletes the App, its machines, and every release image; returns images: {released, pending}. Its volumes keep a final snapshot for 7 days |
apps.start(appId) |
POST …/apps/:id/start |
Sets desired_state to running and warms the runtime. A static App answers 409 static_app_no_runtime |
apps.stop(appId) |
POST …/apps/:id/stop |
Suspends compute now; the next request resumes it. A static App answers 409 static_app_no_runtime: it has no runtime and serves while it has an active deployment |
apps.rollback(appId, deploymentId) |
POST …/apps/:id/rollback |
Creates a NEW release from a ready release’s artifact and config and rolls it out like a deploy (202). Volumes keep their data |
Artifacts are the immutable input to a deployment:
| Method | Wraps | What it does |
|---|---|---|
apps.artifacts.register(input) |
POST …/apps/artifacts |
Registers an archive or an oci_image; returns the upload URL for an archive |
apps.artifacts.uploadArchive(bytes, options?) |
— | Registers, uploads, hashes, and finalizes one .tar.gz in a single call |
apps.artifacts.finalize(artifactId, input) |
POST …/apps/artifacts/:id/finalize |
Confirms sha256 and size_bytes for a manual upload |
Releases (deployments) are immutable and numbered:
| Method | Wraps | What it does |
|---|---|---|
apps.deployments.create(appId, input) |
POST …/apps/:id/deployments |
Starts a release from an artifact, a source, and an optional release config |
apps.deployments.list(appId) |
GET …/apps/:id/deployments |
Lists the deployment history |
apps.deployments.get(appId, deploymentId) |
GET …/deployments/:did |
Reads one deployment plus its events |
apps.deployments.logs(appId, deploymentId, options?) |
GET …/deployments/:did/logs |
Reads runtime logs with a cursor |
apps.deployments.remove(appId, deploymentId) |
DELETE …/deployments/:did |
Deletes one deployment and its image; 409 for the live deployment or an in-progress build. Returns image: 'released' | 'pending' | 'none' |
Machines, scale, and exec (a server App; an App with no machines answers
409 app_no_machines):
| Method | Wraps | What it does |
|---|---|---|
apps.machines.list(appId) |
GET …/apps/:id/machines |
Every machine: machine_id, process_group, state, release, vm, volume |
apps.machines.start/stop/restart(appId, machineId) |
POST …/machines/:mid/{start,stop,restart} |
Acts on one machine; resolves with the machine list |
apps.machines.destroy(appId, machineId) |
DELETE …/machines/:mid |
Destroys one machine. Its volume stays; its group runs one machine fewer |
apps.scale(appId, count) |
PUT …/apps/:id/scale |
Machines per process group ({ worker: 3 }, 0–20), applied in the background (202). Resolves with scale and machines |
apps.exec(appId, { command, machine_id?, timeout_s? }) |
POST …/apps/:id/exec |
Runs one command in a running machine (project.app.admin; audited). timeout_s 1–300, default 60. Resolves with exit_code, stdout, stderr (1 MiB each), truncated |
Volumes (persistent disks):
| Method | Wraps | What it does |
|---|---|---|
apps.volumes.list(appId) |
GET …/apps/:id/volumes |
Every volume: name, process_group, machine_id, size_gb, status, attached, snapshot_retention_days |
apps.volumes.create(appId, { name, size_gb, process_group?, snapshot_retention_days? }) |
POST …/volumes |
Pre-creates a volume; a later machine of its group with a mount of that name takes it |
apps.volumes.update(appId, volumeId, { size_gb?, snapshot_retention_days? }) |
PATCH …/volumes/:vid |
Grows a volume (never shrinks; at most 100 GB) or changes its retention (1–60 days) |
apps.volumes.destroy(appId, volumeId, confirmName) |
DELETE …/volumes/:vid?confirm=<name> |
project.app.admin. Refused with 409 volume_attached while a machine mounts it. Keeps a final snapshot 7 days |
apps.volumes.snapshots(appId, volumeId) |
GET …/volumes/:vid/snapshots |
daily, manual, and final snapshots |
apps.volumes.snapshot(appId, volumeId) |
POST …/volumes/:vid/snapshots |
A manual snapshot now |
apps.volumes.restore(appId, volumeId, snapshotId) |
POST …/volumes/:vid/restore |
project.app.admin. The machine stops, the volume returns to the snapshot, the machine starts on it (202) |
apps.volumes.fork(appId, volumeId, { name, snapshot_id? }) |
POST …/volumes/:vid/fork |
A new, unattached volume in the same App, from a snapshot or the current contents |
Sign-in tokens (capability member_tokens; 409 app_capability_unsupported
when the App lacks it):
| Method | Wraps | What it does |
|---|---|---|
apps.token(appId) |
POST …/apps/:id/token |
A 15-minute sign-in token for the App, naming the caller |
Access is the App’s own authorization policy:
| Method | Wraps | What it does |
|---|---|---|
apps.access.get(appId) |
GET …/apps/:id/access |
Reads the policy. Needs project.app.read |
apps.access.update(appId, input) |
PATCH …/apps/:id/access |
Replaces the policy and bumps its revision |
apps.access.session(appId) |
POST …/apps/:id/access-session |
Mints a five-minute URL that exchanges into a host-only cookie |
Deploy a static site
uploadArchive does the whole artifact handshake: it registers the artifact,
checks it against max_bytes, PUTs the bytes, computes the SHA-256, and
finalizes.
const apps = beeuni.project(projectId).apps;
const app = await apps.create({ slug: 'docs', name: 'Docs' });
const artifact = await apps.artifacts.uploadArchive(tarGzBytes, {
onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`),
});
const deployment = await apps.deployments.create(app.app_id, {
artifact_id: artifact.artifact_id,
source: { kind: 'static', spa: true },
});
console.log(app.url, deployment.status); // https://…apps.beeuni.ai queued
create accepts the machine fields too: cpu, memory_gb,
disk_gb, idle_timeout_seconds, and always_on. Omit them for the defaults.
Apps have no monthly budget: account credits pay for compute. An always-on
server App bills 24/7; an on-demand one only while awake.
always_on: true runs a server App 24/7
(websockets, background loops); false stops it after idle_timeout_seconds
and wakes it on the next request. A static App has no runtime: always_on is
false.
The API ignores monthly_budget_usd if an older client sends it (200, no
error). No App response carries it.
uses lists the Apps, by slug,
this App may get sign-in tokens for and reach through its bindings mount; the
default is none, and update(appId, { uses }) replaces the list (see
Apps an App uses).
create and update return warnings, which is always [].
const app = await beeuni.project(projectId).apps.create({ slug: 'worker', name: 'Worker', always_on: true });
console.log(app.always_on, app.hosting_type); // true null (until the first deploy)
Wait for the deployment by polling its status:
async function waitForReady(appId: string, deploymentId: string) {
for (;;) {
const { deployment } = await apps.deployments.get(appId, deploymentId);
if (deployment.status === 'ready') return deployment;
if (deployment.status === 'failed' || deployment.status === 'cancelled') {
throw new Error(deployment.error ?? deployment.error_code ?? deployment.status);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
The status values are queued, validating, building, provisioning,
checking, ready, failed, and cancelled.
Deploy an OCI image
Register the immutable image reference, then declare the process command and the public target port:
const registered = await apps.artifacts.register({
kind: 'oci_image',
image: 'ghcr.io/hekmat/service:2026-08-07',
});
await apps.deployments.create(app.app_id, {
artifact_id: registered.artifact.artifact_id,
source: {
kind: 'oci_image',
image: 'ghcr.io/hekmat/service:2026-08-07',
command: ['node', 'server.js'],
port: 3000,
readiness_path: '/health',
},
});
register returns upload: null for an oci_image. Only an archive gets an
upload URL.
CreateAppDeploymentInput also accepts environment (non-secret runtime
values), secrets (runtime key to project secret name), and config
(AppReleaseConfigInput, the release config). An invalid config
answers 400 app_config_invalid with issues: [{ path, message }]; a mount
on a runtime without volumes answers 400 app_volumes_unsupported.
await apps.deployments.create(app.app_id, {
artifact_id: registered.artifact.artifact_id,
source: { kind: 'oci_image', image, command: ['node', 'server.js'] },
config: {
processes: { app: '', worker: 'node worker.js' },
http_service: { internal_port: 3000, access: 'app', processes: ['app'] },
mounts: [{ source: 'data', destination: '/data', initial_size_gb: 10, processes: ['app'] }],
deploy: { release_command: 'node migrate.js' },
},
});
Read runtime logs
let cursor = 0;
for (;;) {
const page = await apps.deployments.logs(app.app_id, deployment.deployment_id, {
after: cursor,
limit: 200,
});
for (const entry of page.entries) console.log(entry.source, entry.line);
cursor = page.next_cursor;
if (page.entries.length === 0) break;
}
Each entry carries cursor, time, source (app, appd, caddy), and
line. A caddy line is the access log: method, URI, status, duration and
response headers. It never contains request headers, because those carry the
gate’s credentials (the viewer token and identity, the provider ingress token).
Machines, scale, and volumes
const apps = beeuni.project(projectId).apps;
const machines = await apps.machines.list(app.app_id);
await apps.scale(app.app_id, { worker: 3 }); // 202: applied in the background
const { exit_code, stdout } = await apps.exec(app.app_id, { command: ['ls', '/data'] });
const [data] = await apps.volumes.list(app.app_id);
const snapshot = await apps.volumes.snapshot(app.app_id, data.volume_id);
await apps.volumes.restore(app.app_id, data.volume_id, snapshot.snapshot_id); // project.app.admin
const copy = await apps.volumes.fork(app.app_id, data.volume_id, { name: 'data_copy' });
A machine keeps its machine_id across releases; each release replaces its
box (instance_id). One machine mounts a volume at a time. A restore is a
new version of the volume: the replaced contents stay in its history. A
snapshot of a mounted volume can miss its last ~1 s of writes.
A Convex backend
A Convex backend is an ordinary server App with a volume, made from the
convex template (beeuni apps launch --template convex --name db; see
Template convex).
The SDK has no Convex-specific call. Read the admin key from the machine with
exec, and give a UI App access to the backend with uses:
const db = (await apps.list()).find((a) => a.slug === 'db')!;
const { stdout } = await apps.exec(db.app_id, { command: ['sh', '-c', 'cd /convex && ./generate_admin_key.sh'] });
// The admin key controls all code and data: never log it or ship it to a browser.
await apps.update(site.app_id, { uses: ['db'] }); // the site may mint db tokens and bind to it
db.services holds the backend’s URLs: http (the Convex client URL) and
site (HTTP actions).
Your App already knows who is looking
An App hosted by Beeuni is opened by someone Beeuni already signed in. The Apps gate authenticates them before your first byte is served, so your App needs no login of its own — no second password, no consent screen, no redirect.
In the browser:
import { createBeeuni, beeuniAppViewerToken } from '@beeuni/sdk';
import { useBeeuniAppViewer } from '@beeuni/sdk/react';
const beeuni = createBeeuni({
backendUrl: '/_beeuni/api/v1', // the gate, on your App's own origin
getToken: beeuniAppViewerToken(), // the viewer's own App-scoped token
});
function Header() {
const { status, viewer } = useBeeuniAppViewer();
return <span>{status === 'viewer' ? viewer.email : 'Signed out'}</span>;
}
The browser calls /_beeuni/api/v1/* on your App’s own origin, never
https://api.beeuni.ai/v1 directly: the API answers no CORS preflight from
an App origin, so a direct call fails in the browser. The gate forwards each
call to the Beeuni API as the signed-in viewer, with the viewer’s App-scoped
token. Rules:
- It needs
viewer_token_scope: 'api'. Otherwise it answers403 viewer_api_disabled. - It acts only for a viewer the gate signed in (Beeuni login or an access
link). With none, it answers
401 no_viewer_identity. - It answers a request from the App’s own origin only. A cross-site request
answers
403 cross_site_request. - It forwards
/v1/*except/v1/oauth/*and MCP endpoints, which answer404. Your App’s cookie and its ownAuthorizationheader never reach the API, and the API cannot set a cookie on your App.
On your App’s server, the gate signs the identity into every request:
import { readAppViewer, createAppViewerBeeuni } from '@beeuni/sdk/server';
const viewer = await readAppViewer(request);
// { userId, email, name, picture, groupIds, groups, role, projectId, accountId, appId, accessMode, token }
if (!viewer) return new Response('Not found', { status: 404 });
// and, for an `api`-scoped App, act as them:
const beeuni = await createAppViewerBeeuni(request, { backendUrl });
await beeuni.projects.list(); // their projects, their role
readAppViewer verifies an HMAC over the header with BEEUNI_APP_VIEWER_SECRET,
which Beeuni injects into your App at deploy. A forged header never passes: the
gate deletes any client-supplied copy before forwarding, and the signature is
made with a secret derived per App.
How much your App is told
One setting on the App’s access policy — Settings → Access in Beeuni,
beeuni apps access <app> --viewer off|identity|api, or viewer_token_scope
on PATCH /projects/:id/apps/:appId/access:
| Scope | Your App receives |
|---|---|
identity (default) |
The viewer’s id, email, name, picture, account role, and Beeuni groups (ids and names, in this App’s account only), plus a profile email token. Enough to show each person their own data and to gate by group. |
api |
The above, and a token that acts as that person on the Beeuni API — bounded by their own role. |
off |
Nothing. |
The token is never the user’s Beeuni session: it lasts an hour, carries only those scopes, and every token an App minted dies when the App is deleted or its access policy changes.
Using the viewer token
- On your server, read it per request. The gate puts a live token in
x-beeuni-app-viewer-tokenon every proxied request. Send it asAuthorization: Bearerto the Beeuni API —createAppViewerBeeuni(request)does exactly that. Build one client per request; do not store the token. - After an access-policy change, old tokens stop working. The next request
through the gate carries a new one. In the browser,
beeuniAppViewerToken()recovers by itself: when the API refuses its token, the SDK re-reads/_beeuni/viewerand replays the call once. - The viewer’s role is the ceiling. The token can do exactly what the
viewer can do in Beeuni, never more. On a project with agent permissions, a
memberneeds a grant on the agent your App starts: without one,POST /projects/:id/sessionsanswers403 no_agent_access. Grant your viewers the agent, not a broader role. - Who has a viewer. A person who opens the App with their Beeuni login — or
with an access link, which signs them in as that person — has one. A
passwordApp has none: the password proves a shared secret, not a person. ApublicApp has one only for a visitor who arrived through an access link; the bare URL stays anonymous. - It is a credential. Beeuni leaves request headers out of the App’s access
log (
beeuni apps logs). Do not log the header in your own code.
An App served on its own domain (not *.apps.beeuni.ai) has no gate in
front of it — use Sign in with Beeuni there instead.
One member, on every runtime
Beeuni vouches for the same person in three places: the browser asks the gate
(/_beeuni/viewer), the App’s server reads the signed header, and an App that
verifies sign-in tokens (a Convex backend, or your own server) receives a
Beeuni-signed token. readBeeuniMember turns each of those shapes into one
BeeuniMember, and requireBeeuniMember decides who may proceed. No
framework and no dependency: the same code runs in a browser, Node, Bun, a
Worker and a Convex function.
import {
readBeeuniMember, requireBeeuniMember, verifyBeeuniToken,
beeuniToken, beeuniBinding, fetchBeeuniAppViewer, BeeuniMemberError,
} from '@beeuni/sdk';
// Browser: who is looking (null when nobody).
const me = readBeeuniMember(await fetchBeeuniAppViewer());
// Browser → an App this App uses, on the same origin: its URL and a token fetcher.
const db = beeuniBinding('db');
const convex = new ConvexReactClient(db.url);
convex.setAuth(db.token);
// React: read the member with useQuery(api.members.me), never useConvexAuth() (see below).
// Browser: a token for this App itself, or for an App it uses.
const token = await beeuniToken()();
const dbToken = beeuniToken({ audience: 'db' });
// A Convex function: the runtime verified the token; enforce the rules.
const member = requireBeeuniMember(await ctx.auth.getUserIdentity(), { groups: ['Finance'] });
// A server App: verify the bearer. No options = the BEEUNI_AUTH_* env Beeuni injects.
// Any other server: pass the App's `auth` values.
const caller = await verifyBeeuniToken(bearer, { issuer: app.auth.issuer, audience: app.auth.audience, jwks: app.auth.jwks_uri });
In React, do not call useConvexAuth(): it throws under a plain
ConvexProvider (it needs ConvexProviderWithAuth, and beeuniBinding is not
verified with that provider). Read the signed-in member with a query:
// convex/members.ts
export const me = query({ args: {}, handler: async (ctx) => readBeeuniMember(await ctx.auth.getUserIdentity()) });
const me = useQuery(api.members.me); // undefined: loading. null: signed out. Otherwise the member.
readBeeuniMember returns null for an anonymous caller. Use
requireBeeuniMember only where the function must throw.
| Field | Value |
|---|---|
userId |
Beeuni user id. Stable; key rows on it. |
email, name, picture |
From the member’s profile. null when unknown. |
groups, groupIds |
The member’s Beeuni groups in this account, by name and by id. |
role |
Account role: owner, admin or member. |
accountId, projectId |
Where the App lives. |
requireBeeuniMember(claims, { groups, roles }) returns the member or throws
BeeuniMemberError with code unauthenticated (nobody, or a token that
failed a check) or forbidden (outside the groups or roles). Each list means
“any one of these”; a group matches by name or id; an empty list admits nobody.
Sign-in tokens
One issuer per project signs every App’s tokens: iss =
<API origin>/v1/projects/<projectId>, aud = the id of the App the token is
for, 15 minutes. Its key set is public at <issuer>/jwks.json, named by
<issuer>/.well-known/openid-configuration. Every App carries the values that
verify its tokens: app.auth.issuer, app.auth.audience, app.auth.jwks_uri.
A token carries sub, email, name, picture, groups, group_ids,
role, account_id and project_id; an agent’s token carries
kind: 'agent' and no groups or role.
| Function | What it does |
|---|---|
beeuniToken({ audience? }) |
A token fetcher (the shape realtime clients take as their auth callback) for the App itself (default) or an App it uses. Reads /_beeuni/token on the App’s own origin. Cached until shortly before expiry; { forceRefreshToken: true } asks again. Yields null without a signed-in viewer, and null plus one console warning for an App it does not use (403 app_not_linked). Never throws. |
beeuniBinding(slug, { origin? }) |
{ url, token } for an App this App uses: url = <origin>/_beeuni/apps/<slug> (HTTP and WebSocket, same origin, no CORS), token = beeuniToken({ audience: slug }). |
verifyBeeuniToken(bearer, { issuer?, audience?, jwks? }) |
Checks the ES256 signature, expiry (60 s skew), issuer and audience. Defaults: BEEUNI_AUTH_ISSUER, BEEUNI_AUTH_AUDIENCE, BEEUNI_AUTH_JWKS. Beeuni injects all three into every server App’s runtime (issuer: the project; audience: the App’s own id; key set: a data: URI), so verifyBeeuniToken(bearer) works there with no options. Never set BEEUNI_AUTH_* in a server App’s env: the BEEUNI_ prefix is reserved and beeuni validate rejects it. The key set is an object, JSON text, a data: URI, or an https URL (auth.jwks_uri, fetched once per process). Every failure throws BeeuniMemberError, a missing key set or a missing audience included: one project key signs every App’s tokens, so aud is what keeps another App’s token out. audience: false accepts a token for any App of the project. |
apps.token(appId) |
A token naming the caller, from the API (POST …/apps/:id/token). An agent session gets one naming its agent. |
A member you remove from the project or a group loses access within one token lifetime; the App gate refuses them on the next page load. Beeuni groups need the Enterprise plan; roles work on every plan.
Manage access
await apps.access.update(app.app_id, {
mode: 'restricted',
member_ids: [memberId],
group_ids: [groupId],
});
const preview = await apps.access.session(app.app_id);
window.open(preview.url); // valid for five minutes
AppAccessConfig reports password_configured, never the password or its
hash. Set a password with { mode: 'password', password }. Each update
increments revision, which revokes existing App cookies.
Types
Every type below is exported from @beeuni/sdk.
| Type | What it holds |
|---|---|
App |
Identity, capabilities, labels, scale, services (AppServiceSummary[]), machines (total, running, stopped), volumes (count, size_gb), config (the live release’s AppReleaseConfig, or null), uses, used_by, auth, url, access_mode, access_revision, desired_state, active_deployment_id, hosting_type (of the active release: static, sandbox, or null before the first deploy), machine (the serving group’s size in the live release), idle_timeout_seconds, always_on, retained_deployments, warnings (create and update only; always []), last_request_at, viewer_can_access |
AppCapability |
'deployments' | 'rollback' | 'preview' | 'sleep' | 'static' | 'member_tokens' | 'machines' | 'scale' | 'exec' | 'volumes' | 'snapshots' |
AppReleaseConfig · AppReleaseConfigInput |
The resolved release config (processes, services, mounts, vm, deploy) · the authored form (processes, http_service, services, mounts, vm, deploy, and the shorthands port, command, readiness_path, always_on, resources) |
AppServiceConfig · AppServiceAccess · AppAutoStop · AppCheck |
name, internal_port, protocol, access, auto_stop_machines, auto_start_machines, min_machines_running, processes, checks · 'public' | 'app' | 'admins' · 'off' | 'stop' | 'suspend' · type, port, path, interval_s, timeout_s, grace_period_s |
AppMountConfig · AppVmConfig · AppDeployStrategy |
source, destination, initial_size_gb, snapshot_retention_days, processes · cpu, memory_gb, disk_gb, processes · 'rolling' | 'immediate' | 'bluegreen' | 'canary' |
AppServiceSummary |
name, protocol, access, internal_port, processes, url (null when the host label would be too long) |
AppMachine · AppMachineState |
machine_id, instance_id, process_group, state, release (deployment_id, version), vm, volume, created_at, started_at, stopped_at · 'started' | 'starting' | 'stopped' | 'stopping' | 'provisioning' |
AppVolume · AppVolumeSnapshot · CreateAppVolumeInput |
volume_id, name, process_group, machine_id, size_gb, status, attached, snapshot_retention_days, created_at · snapshot_id, kind (daily, manual, final), commit_id, created_at · name, size_gb, process_group, snapshot_retention_days |
AppExecInput · AppExecResult |
command, machine_id, timeout_s · machine_id, exit_code, stdout, stderr, truncated |
AppAuth |
issuer, audience (the App id), jwks_uri: the public values that verify the App’s sign-in tokens |
AppToken |
token, expires_at |
BeeuniTokenOptions · BeeuniTokenFetcher · BeeuniBinding · VerifyBeeuniTokenOptions |
The sign-in token helpers above |
AppDeployment |
version, status, source_kind, hosting_type (static: served from storage, no runtime; sandbox: machines), hosting_provider, runtime_spec, build_spec, error_code, attempt_count, created_by, actor_type, source_session_id, config (the release’s resolved config), strategy, release_command_status (running, succeeded, failed or null) |
AppRollback |
What apps.rollback resolves with: the App plus the queued release in deployment |
AppDeploymentDetail |
One deployment plus its events: lifecycle events and the build log (build_log lines, and one log_truncated event when the build printed more than 5,000 lines) |
AppAccessConfig |
mode, revision, member_ids, group_ids, password_configured |
AppAccessMode |
'private' | 'project' | 'restricted' | 'public' | 'password' |
AppSource |
StaticAppSource | BundleAppSource | DockerfileAppSource | OciImageAppSource |
AppArtifact |
kind, status, sha256, size_bytes, image_reference |
AppLogEntry · AppLogsResponse |
One log line, and one page plus next_cursor |
apps.list() and useProjectApps return only the Apps the caller may open
under each App’s access policy (private: the creator; restricted: the listed
members and groups; project, password and public: the project team). A project
manager sees every App, so a private one stays manageable when its creator
leaves. viewer_can_access is therefore true on every listed App. A get of
an App the caller cannot open answers 404, not 403. Treat undefined as
unknown, not as denied.
AppAccessMode is a per-resource visibility setting on top of the role model,
not a role. restricted names users and groups — the same principal types the
role model uses. See
Accounts & access.
DockerfileAppSource and OciImageAppSource require command. port is the
primary service’s port: omit it only for a worker-only release (a config
with processes and no service). StaticAppSource and BundleAppSource take
neither.
React hooks
@beeuni/sdk/react exports seven hooks for Apps.
useProjectApps(projectId)
The Apps the caller may open, plus its lifecycle mutations. Every mutation invalidates the inventory on success.
import { useProjectApps } from '@beeuni/sdk/react';
function AppList({ projectId }: { projectId: string }) {
const apps = useProjectApps(projectId);
if (!apps.data) return null;
return (
<ul>
{apps.data.map((app) => (
<li key={app.app_id}>
<a href={app.url}>{app.slug}</a>
<button onClick={() => apps.stop.mutate(app.app_id)}>Stop</button>
</li>
))}
</ul>
);
}
It returns the query fields plus create, update, start, stop, and
remove.
useAppDeployments(projectId, appId)
The immutable deployment history, refetched every 5 s so a running build advances on its own.
const deployments = useAppDeployments(projectId, appId);
await deployments.deploy.mutateAsync({
artifact_id: artifact.artifact_id,
source: { kind: 'static', spa: true },
});
await deployments.rollback.mutateAsync(previousDeploymentId);
Both mutations invalidate the deployment list and the App inventory.
useAppDeployment(projectId, appId, deploymentId)
One deployment and its events, build log included (AppDeploymentDetail). It
polls every 2 s until the deployment is ready, failed or cancelled. Pass
null as deploymentId to keep it idle.
const detail = useAppDeployment(projectId, appId, deploymentId);
const log = (detail.data?.events ?? [])
.map((event) => (event.type === 'build_log' ? event.message : `[${event.type}] ${event.message}`))
.join('\n');
A build keeps its first 2,500 and its last 2,500 lines. Lines are written in batches, at most 1 s after they are printed. A failed or cancelled deployment keeps its build log for 14 days.
useAppAccess(projectId, appId, options?)
The access policy and a short-lived access session. Both halves are separate queries, and each one is optional.
const access = useAppAccess(projectId, appId, {
policy: canEditAccess,
session: app.viewer_can_access,
});
access.policy.data; // AppAccessConfig
access.session.data; // { url, expires_at }
await access.update.mutateAsync({ mode: 'project' });
| Option | Default | Use false when |
|---|---|---|
policy |
true |
The surface only previews the App. GET …/access is an administrative read and answers 403 for a caller without project-manager permissions. |
session |
true |
The caller may not open the App. Pass app.viewer_can_access (always true for a listed App). |
A grid of Apps that leaves both options at true fires one policy read and one
session mint per App, and each is a 403 for a member who may not open that
App.
useAppMachines(projectId, appId)
An App’s machines, polled every 5 s, plus start, stop, restart,
destroy (each takes a machineId) and scale (takes
{ [group]: count }). Each mutation refreshes the machines and the App
inventory. appMachinesKey(projectId, appId) is the query key.
const machines = useAppMachines(projectId, app.app_id);
await machines.scale.mutateAsync({ worker: 2 });
await machines.restart.mutateAsync(machineId);
useAppVolumes(projectId, appId)
An App’s volumes, plus snapshot (volumeId), restore
({ volumeId, snapshotId }), fork ({ volumeId, name, snapshotId? }),
extend ({ volumeId, sizeGb }), and destroy ({ volumeId, name }). Each
mutation refreshes the volumes. appVolumesKey(projectId, appId) is the query
key.
const volumes = useAppVolumes(projectId, app.app_id);
await volumes.snapshot.mutateAsync(volumeId);
await volumes.restore.mutateAsync({ volumeId, snapshotId });
useAppVolumeSnapshots(projectId, appId, volumeId)
One volume’s snapshots. Pass null as volumeId to keep it idle.
Errors
import { featureDisabledKey, isFeatureDisabledError } from '@beeuni/sdk';
try {
await beeuni.project(projectId).apps.list();
} catch (error) {
if (isFeatureDisabledError(error)) {
console.log(`${featureDisabledKey(error)} is off for this project`);
}
}
Other answers you should handle:
409for a duplicate slug,402withapp_quota_exceededwhen the account is at its App limit, and400withapp_machine_out_of_rangefor a spec outside its bounds.400 app_not_found(slugs) for an unknown slug inuses.409 app_capability_unsupported(capability) for a call the App does not offer.- A deploy:
400 app_config_invalid(issues),400 app_volumes_unsupported. A rollback:409 release_not_ready. - Machines and exec:
409 app_no_machines,404 machine_not_found,409 machine_not_running,400 process_group_unknown,400 scale_out_of_range,400 command_required. - Volumes:
400 volume_name_invalid,volume_size_invalid,volume_shrink_refused,volume_retention_invalid,snapshot_invalid,confirmation_required;404 volume_not_found;409 volume_attached.
token() answers with Cache-Control: no-store.