---
title: Apps
description: 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](/docs/feature-flags/apps).

```ts
const apps = beeuni.project(projectId).apps;
```

:::info[Apps is a feature flag]
Every Apps route answers `403` with `{ error, code: "feature_disabled", feature: "apps" }`
until the project turns Apps on. Use `isFeatureDisabledError(error)` to branch
on it. See [Feature flags](/docs/feature-flags).
:::

## 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`, `PUT`s the bytes, computes the SHA-256, and
finalizes.

```ts
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](/docs/feature-flags/apps#apps-an-app-uses-sign-in-tokens-and-bindings)).

`create` and `update` return `warnings`, which is always `[]`.

```ts
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:

```ts
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:

```ts
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`.

```ts
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

```ts
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

```ts
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`](/docs/feature-flags/apps#template-convex-a-convex-backend)).
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`:

```ts
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:

```ts
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 answers
  `403 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 answer
  `404`. Your App's cookie and its own `Authorization` header 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:

```ts
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-token` on every proxied request. Send it as
  `Authorization: Bearer` to 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/viewer` and 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
  `member` needs a grant on the agent your App starts: without one,
  `POST /projects/:id/sessions` answers `403 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
  `password` App has none: the password proves a shared secret, not a person. A
  `public` App 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](/docs/sdk/sign-in) 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.

```ts
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:

```ts
// convex/members.ts
export const me = query({ args: {}, handler: async (ctx) => readBeeuniMember(await ctx.auth.getUserIdentity()) });
```

```tsx
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

```ts
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](/docs/accounts#per-feature-access-settings).

`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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

```ts
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:

- `409` for a duplicate slug, `402` with `app_quota_exceeded` when the account
  is at its App limit, and `400` with `app_machine_out_of_range` for a spec outside its bounds.
- `400 app_not_found` (`slugs`) for an unknown slug in `uses`.
- `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`.
