MachineMachine
MachineMachine
MachineGetting startedQuickstartAccounts & access
Your project
AgentsLegacy kortix.tomlManifest referenceModelsAgent permissionsSecrets
Running work
Change requestsRuntime & sandboxSessions
Connect & automate
ComputersConnectorsInkbox: iMessage, SMS, email & callsMCP serverPixio: images, video & audioRemindersSlack & channelsMicrosoft TeamsTriggers
Feature flags
Apps

Develop

CLI
TypeScript SDKFull exampleAuthenticationSign in with KortixSessionsAppsReact hooksSDK reference
Machine as a BackendAPI reference
GitHub
SDK

Apps

Create, deploy, and control Kortix Apps from the SDK and from React.

Apps are project-scoped serverless deployments. This page covers the SDK surface: the apps facade on a project handle, the artifact and deployment calls, the access calls, the exported types, and the React hooks.

For what an App is, the source kinds, the CLI, the stable URL, and cold-wake behavior, read Apps.

typescript
const apps = kortix.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. :::

The apps facade

MethodWrapsWhat it does
apps.list()GET /projects/:pid/appsLists the project's Apps
apps.create(input)POST …/appsCreates an App and assigns its stable URL
apps.get(appId)GET …/apps/:idReads one App
apps.update(appId, input)PATCH …/apps/:idRenames it or changes machine, idle timeout, or budget
apps.remove(appId)DELETE …/apps/:idDeletes the App, its runtimes, and every deployment image; returns images: {released, pending}
apps.start(appId)POST …/apps/:id/startSets desired_state to running and warms the runtime
apps.stop(appId)POST …/apps/:id/stopSuspends compute now; the next request resumes it
apps.rollback(appId, deploymentId)POST …/apps/:id/rollbackMoves traffic to a ready deployment

Artifacts are the immutable input to a deployment:

MethodWrapsWhat it does
apps.artifacts.register(input)POST …/apps/artifactsRegisters 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/finalizeConfirms sha256 and size_bytes for a manual upload

Deployments are immutable and numbered:

MethodWrapsWhat it does
apps.deployments.create(appId, input)POST …/apps/:id/deploymentsStarts a deployment from an artifact and a source
apps.deployments.list(appId)GET …/apps/:id/deploymentsLists the deployment history
apps.deployments.get(appId, deploymentId)GET …/deployments/:didReads one deployment plus its events
apps.deployments.logs(appId, deploymentId, options?)GET …/deployments/:did/logsReads runtime logs with a cursor
apps.deployments.remove(appId, deploymentId)DELETE …/deployments/:didDeletes one deployment and its image; 409 for the live deployment or an in-progress build. Returns image: 'released' | 'pending' | 'none'

Access is the App's own authorization policy:

MethodWrapsWhat it does
apps.access.get(appId)GET …/apps/:id/accessReads the policy. Needs project.app.read
apps.access.update(appId, input)PATCH …/apps/:id/accessReplaces the policy and bumps its revision
apps.access.session(appId)POST …/apps/:id/access-sessionMints 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.

typescript
const apps = kortix.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.kortix.com  queued

create accepts the machine and budget fields too: cpu, memory_gb, disk_gb, idle_timeout_seconds, and monthly_budget_usd. Omit them for the defaults.

Wait for the deployment by polling its status:

typescript
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:

typescript
const registered = await apps.artifacts.register({
  kind: 'oci_image',
  image: 'ghcr.io/acme/service:2026-08-07',
});

await apps.deployments.create(app.app_id, {
  artifact_id: registered.artifact.artifact_id,
  source: {
    kind: 'oci_image',
    image: 'ghcr.io/acme/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 provider ('daytona' | 'platinum' | 'e2b'). Omit provider to use the server policy.

Read runtime logs

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

Your App already knows who is looking

An App hosted by Kortix is opened by someone Kortix 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:

typescript
import { createKortix, kortixAppViewerToken } from '@kortix/sdk';
import { useKortixAppViewer } from '@kortix/sdk/react';

const kortix = createKortix({
  backendUrl: 'https://api.kortix.com/v1',
  getToken: kortixAppViewerToken(),          // the viewer's own App-scoped token
});

function Header() {
  const { status, viewer } = useKortixAppViewer();
  return <span>{status === 'viewer' ? viewer.email : 'Signed out'}</span>;
}

On your App's server, the gate signs the identity into every request:

typescript
import { readAppViewer, createAppViewerKortix } from '@kortix/sdk/server';

const viewer = await readAppViewer(request);
// { userId, email, groupIds, accountId, appId, accessMode, token }
if (!viewer) return new Response('Not found', { status: 404 });

// and, for an `api`-scoped App, act as them:
const kortix = await createAppViewerKortix(request, { backendUrl });
await kortix.projects.list();               // their projects, their role

readAppViewer verifies an HMAC over the header with KORTIX_APP_VIEWER_SECRET, which Kortix 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 Machine, kortix apps access <app> --viewer off|identity|api, or viewer_token_scope on PATCH /projects/:id/apps/:appId/access:

ScopeYour App receives
identity (default)The viewer's id, email and group ids, plus a profile email token. Enough to show each person their own data.
apiThe above, and a token that acts as that person on the Kortix API — bounded by their own role.
offNothing.

The token is never the user's Kortix 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-kortix-app-viewer-token on every proxied request. Send it as Authorization: Bearer to the Machine API — createAppViewerKortix(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, kortixAppViewerToken() recovers by itself: when the API refuses its token, the SDK re-reads /_kortix/viewer and replays the call once.
  • The viewer's role is the ceiling. The token can do exactly what the viewer can do in Machine, 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 Machine 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. Machine leaves request headers out of the App's access log (kortix apps logs). Do not log the header in your own code.

An App served on its own domain (not *.apps.kortix.com) has no gate in front of it — use Sign in with Kortix there instead.

Manage access

typescript
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 @kortix/sdk.

TypeWhat it holds
AppIdentity, url, access_mode, access_revision, desired_state, active_deployment_id, machine, idle_timeout_seconds, monthly_budget_usd, last_request_at, viewer_can_access
AppDeploymentversion, status, source_kind, hosting_provider, runtime_spec, build_spec, error_code, attempt_count, created_by, actor_type, source_session_id
AppDeploymentDetailOne deployment plus its events
AppAccessConfigmode, revision, member_ids, group_ids, password_configured
AppAccessMode'private' | 'project' | 'restricted' | 'public' | 'password'
AppSourceStaticAppSource | BundleAppSource | DockerfileAppSource | OciImageAppSource
AppArtifactkind, status, sha256, size_bytes, image_reference
AppLogEntry · AppLogsResponseOne log line, and one page plus next_cursor

viewer_can_access answers whether the caller may OPEN the App, which is not the same as whether they can see it listed. A project manager sees every App in the project so a private one stays manageable when its creator leaves. Check this field before asking for an access session. 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 and port. StaticAppSource and BundleAppSource do not.

React hooks

@kortix/sdk/react exports three hooks for Apps.

useProjectApps(projectId)

The project's App inventory plus its lifecycle mutations. Every mutation invalidates the inventory on success.

tsx
import { useProjectApps } from '@kortix/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.

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' });
OptionDefaultUse false when
policytrueThe surface only previews the App. GET …/access is an administrative read and answers 403 for a caller without project-manager permissions.
sessiontrueThe caller may see the App but not open it. Pass app.viewer_can_access.

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.

Errors

typescript
import { featureDisabledKey, isFeatureDisabledError } from '@kortix/sdk';

try {
  await kortix.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 or app_budget_out_of_range for a spec outside its bounds.

PreviousSessionsNextReact hooks

On this page

The apps facadeDeploy a static siteDeploy an OCI imageRead runtime logsYour App already knows who is lookingHow much your App is toldUsing the viewer tokenManage accessTypesReact hooksuseProjectApps(projectId)useAppDeployments(projectId, appId)useAppAccess(projectId, appId, options?)Errors