Skip to content

App Manifest

Every Forge app is defined by a ptkl.config.js file — a default export that describes the app's identity, entry points, lifecycle scripts, services, and permissions.


Example

import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

export default {
    name: 'invoice-app',
    version: '1.0.0',
    icon: 'icon.svg',
    type: 'platform',
    label: {
        locales: {
            en_US: 'Invoice App',
            sr: 'Aplikacija za fakture',
        }
    },

    views: {
        main: path.resolve(__dirname, 'src/main.tsx'),
        'public-invoice': {
            entry: path.resolve(__dirname, 'src/publicInvoice.tsx'),
            access: 'public',
            permissions: ['invoices:read', 'documents:get'],
        },
    },

    services: [
        {
            name: 'track-invoice',
            script: path.resolve(__dirname, 'src/services/trackInvoice.ts'),
            access: 'public',
            permissions: ['invoices:read'],
            label: { locales: { en_US: 'Track Invoice' } },
            description: { locales: { en_US: 'Look up invoice status by tracking number' } },
        },
        {
            name: 'create-invoice',
            script: path.resolve(__dirname, 'src/services/createInvoice.ts'),
            access: 'platform',
            permissions: ['invoices:read', 'invoices:write'],
        },
    ],

    scripts: {
        install: path.resolve(__dirname, 'src/scripts/install.ts'),
        uninstall: path.resolve(__dirname, 'src/scripts/uninstall.ts'),
    },

    distPath: path.resolve(__dirname, "dist"),

    permissions: [
        "invoices",
        "invoices:read",
        "invoices:write",
    ],

    install_permissions: [
        "manage base_components",
    ],

    runtime_permissions: [
        "invoices:read",
        "invoices:write",
        "documents:get",
    ],
}

Fields

name

Type string
Required Yes

Unique identifier for the app. Used as the storage key and to match the app record when uploading new versions.

version

Type string
Required Yes

Semantic version string (e.g. "1.0.0"). Each uploaded version is stored separately — an app can have up to 5 versions.

icon

Type string
Required No

Path or filename for the app icon. Used as the favicon when the app is loaded in the platform.

type

Type "platform" | "public" | "service"
Required No
Default "platform"

Determines the app's visibility, infrastructure provisioning, and what resources it can expose.

Value Description
platform Internal app. Views default to access: "platform" but individual views can be set to "public". Can declare services with either access scope.
public Public-facing app. All views and services must be access: "public". Automatically receives an API role, API user, API token, and a generated subdomain (e.g. happy-cat.ptkl.app). Enables ssrRenderer.
service Headless API-only app. Cannot declare views. All services must be access: "public". Accessed exclusively through service domains.

label

Type { locales: Record<string, string> }
Required No

Internationalized display name shown in the platform UI and used as document.title when the app loads.

label: {
    locales: {
        en_US: 'My App',
        sr: 'Moja Aplikacija',
    }
}

views

Type Record<string, string \| ViewDefinition>
Required Yes (except for type: "service")

Map of view names to their entry points. Each value can be a simple file path string or an object with access control.

views: {
    main: path.resolve(__dirname, 'src/main.tsx'),
}

When the value is a string, it is treated as the entry point path. The view inherits its access scope from the app type: "platform" for platform apps, "public" for public apps.

views: {
    main: path.resolve(__dirname, 'src/main.tsx'),
    'public-invoice': {
        entry: path.resolve(__dirname, 'src/publicInvoice.tsx'),
        access: 'public',
        permissions: ['invoices:read', 'documents:get'],
    },
}

Object entries accept:

Field Type Description
entry string Path to the view's entry point file. Required.
access "platform" | "public" Access scope. Defaults to the app type's default.
permissions string[] Subset of runtime_permissions this view needs. Used to mint a scoped token for the view's API proxy.

Both formats can be mixed in the same views object. The main view is the primary entry point loaded by the platform shell.

Public views are standalone SPAs

Views with access: "public" are served as standalone single-page applications without the platform shell (no sidebar, no header, no $forge global). They are accessed through {uuid}.public.ptkl.app/{view-name}.

Access scope constraints

  • type: "public" apps — all views must be access: "public"
  • type: "service" apps — views are not allowed
  • type: "platform" apps — views can be either "platform" or "public"

Limits: Maximum 10 views per app. View names must match ^[a-z][a-z0-9-]{0,62}[a-z0-9]$ (lowercase, hyphens allowed, 2–64 characters). The name main is also allowed.

services

Type ServiceDefinition[]
Required No

Array of service definitions. Services are server-side scripts that execute in an isolated sandbox and are callable via HTTP. See Services for the full execution model.

services: [
    {
        name: 'track-invoice',
        script: path.resolve(__dirname, 'src/services/trackInvoice.ts'),
        access: 'public',
        permissions: ['invoices:read'],
        label: { locales: { en_US: 'Track Invoice' } },
        description: { locales: { en_US: 'Look up invoice status by tracking number' } },
    },
]

Each service definition has:

Field Type Required Description
name string Yes Unique name for the service. Appears in the URL path. Must match ^[a-z][a-z0-9-]{1,62}[a-z0-9]$.
script string Yes Path to the service script source file.
access "platform" | "public" No Access scope. Defaults to "public" for public and service apps.
permissions string[] No Subset of runtime_permissions the service needs. A scoped token is minted with these permissions.
label { locales: Record<string, string> } No Display name for the service.
description { locales: Record<string, string> } No Human-readable description of what the service does.

Access scope constraints

  • type: "public" apps — all services must be access: "public"
  • type: "service" apps — services can be either "platform" or "public"
  • type: "platform" apps — services can be either "platform" or "public"

Limits: Maximum 20 services per app. Service permissions must be a subset of the app's runtime_permissions.

scripts

Type { install?: string, uninstall?: string }
Required No

Paths to lifecycle scripts that run during deployment and removal. See Lifecycle Scripts for details.

scripts: {
    install: path.resolve(__dirname, 'src/scripts/install.ts'),
    uninstall: path.resolve(__dirname, 'src/scripts/uninstall.ts'),
}
  • install — Runs every time a version is deployed. Must be idempotent.
  • uninstall — Runs when a version is revoked or the app is deleted.

distPath

Type string
Required No

Absolute path to the build output directory. This is where the bundled assets (main.bundle.js, main.css, etc.) are written during the build process.

distPath: path.resolve(__dirname, "dist"),

ssrRenderer

Type string
Required No
Available Only when type is "public"

Path to the server-side rendering entry point. When provided, the platform uses this file to pre-render the app on the server before sending it to the client. This option is only available for public apps.

ssrRenderer: path.resolve(__dirname, 'src/ssr/renderer.tsx'),

permissions

Type string[]
Required No

List of permissions that the app exposes to the platform's permission system. Once deployed, these permissions become available to assign to any role on the platform — allowing fine-grained access control scoped to the app's functionality.

permissions: [
    "reviewer",
    "documents",
    "documents:read",
    "documents:write",
    "documents:delete",
]

Permissions are additive

The app declares what permissions exist. Platform administrators then assign these permissions to roles through the standard role management interface. Users with those roles will have the corresponding access when using the app.

install_permissions

Type string[]
Required No

List of platform permissions the install and uninstall scripts are allowed to use. Before a user installs the app, they are shown a consent screen listing exactly these permissions — similar to an OAuth scope screen. The script runs with a token scoped to only these permissions and cannot act outside of them.

If omitted, the install script still executes but receives a token with no permissions. Any call to a Protokol API that requires authorization will fail — the script can only perform work that does not depend on platform API access.

install_permissions: [
    "manage base_components",
    "manage integrations",
]

runtime_permissions

Type string[]
Required No

List of platform permissions the app is allowed to use at runtime. This is the permission pool for three consumers:

  1. Platform views — When a user launches the app, the runtime token contains the intersection of runtime_permissions and the user's own permissions.
  2. Public views — The view's permissions array (a subset of runtime_permissions) determines the scoped token. No user context is involved.
  3. Services — Same as public views: the service's permissions array is used to mint a scoped token.

If omitted, the app's UI and services receive no platform permissions at runtime.

runtime_permissions: [
    "invoices:read",
    "invoices:write",
    "documents:get",
]

entitlements

Type string[]
Required No

List of platform permissions that are unconditionally added to the app's runtime token, regardless of the launching user's own roles. Unlike runtime_permissions, entitlements bypass the user-intersection step — the app always receives these permissions when it starts.

Use entitlements for capabilities the app always needs to function correctly, independent of who is using it.

entitlements: [
    "documents:delete",
    "documents:archive",
]

Entitlements bypass user permission checks

Because entitlements are merged unconditionally, they should only be used for permissions the app genuinely requires to operate — not to grant elevated access to the end user.

Combined limit

The total count of runtime_permissions and entitlements combined must not exceed 100 entries. This limit is enforced during bundle upload and installation.


Bundle Output

After building, the app is packaged as a .tar.gz bundle with the following structure:

invoice-app-1.0.0.tar.gz
├── manifest.json              # Generated metadata
├── main.bundle.js             # Platform view bundle
├── main.css
├── public-invoice.bundle.js   # Public view bundle
├── public-invoice.css
├── scripts/
│   ├── install.js
│   └── uninstall.js
└── services/
    ├── track-invoice.js
    └── create-invoice.js

The manifest.json in the bundle contains the serialized metadata — name, version, label, icon, permissions, views with resolved access scopes, services, and script paths.

manifest.json Format

{
    "name": "invoice-app",
    "version": "1.0.0",
    "type": "platform",
    "label": { "locales": { "en_US": "Invoice App" } },
    "icon": "data:image/svg+xml;base64,...",
    "permissions": ["invoices", "invoices:read", "invoices:write"],
    "install_permissions": ["manage base_components"],
    "runtime_permissions": ["invoices:read", "invoices:write", "documents:get"],
    "entitlements": [],
    "views": {
        "main": "main.bundle.js",
        "public-invoice": {
            "entry": "public-invoice.bundle.js",
            "access": "public",
            "permissions": ["invoices:read", "documents:get"]
        }
    },
    "services": [
        {
            "name": "track-invoice",
            "script": "services/track-invoice.js",
            "access": "public",
            "permissions": ["invoices:read"],
            "label": { "locales": { "en_US": "Track Invoice" } },
            "description": { "locales": { "en_US": "Look up invoice status by tracking number" } }
        },
        {
            "name": "create-invoice",
            "script": "services/create-invoice.js",
            "access": "platform",
            "permissions": ["invoices:read", "invoices:write"]
        }
    ],
    "scripts": {
        "install": "install.script.js",
        "uninstall": "uninstall.script.js"
    }
}

Views in the manifest use two formats:

  • String ("main.bundle.js") — no explicit access scope; inherits from app type.
  • Object ({ entry, access, permissions }) — view with explicit access scope and optional scoped permissions.

App Types at a Glance

Feature platform public service
Views Yes (platform or public) Yes (public only) No
Services Yes (platform or public) Yes (public only) Yes (platform or public)
Platform shell (sidebar, header) Yes No N/A
Custom domains No Yes No
SSR No Yes N/A
Auto-provisioned API user No Yes No
Lifecycle scripts Yes Yes Yes
UUID-based service domain When services declared When services declared Yes

Examples by Type

An internal app with views inside the platform shell and services with mixed access scopes.

export default {
    name: 'invoice-app',
    version: '1.0.0',
    type: 'platform',
    label: { locales: { en_US: 'Invoice App' } },

    views: {
        main: path.resolve(__dirname, 'src/main.tsx'),
    },

    services: [
        {
            name: 'create-invoice',
            script: path.resolve(__dirname, 'src/services/createInvoice.ts'),
            access: 'platform',
            permissions: ['invoices:read', 'invoices:write'],
        },
    ],

    runtime_permissions: ['invoices:read', 'invoices:write'],
}

A platform app that also exposes public views and services. Public views are standalone SPAs served outside the platform shell at {uuid}.public.ptkl.app/{view-name}. This is useful when a platform app needs both an internal UI for operators and a public-facing page for end users.

export default {
    name: 'invoice-app',
    version: '1.0.0',
    type: 'platform',
    label: { locales: { en_US: 'Invoice App' } },

    views: {
        main: path.resolve(__dirname, 'src/main.tsx'),
        'public-invoice': {
            entry: path.resolve(__dirname, 'src/publicInvoice.tsx'),
            access: 'public',
            permissions: ['invoices:read'],
        },
    },

    services: [
        {
            name: 'create-invoice',
            script: path.resolve(__dirname, 'src/services/createInvoice.ts'),
            access: 'platform',
            permissions: ['invoices:read', 'invoices:write'],
        },
        {
            name: 'track-invoice',
            script: path.resolve(__dirname, 'src/services/trackInvoice.ts'),
            access: 'public',
            permissions: ['invoices:read'],
        },
    ],

    runtime_permissions: ['invoices:read', 'invoices:write'],
}

A headless API-only app — no views, no platform shell. All services must be access: "public".

export default {
    name: 'email-sender',
    version: '1.0.0',
    type: 'service',
    label: { locales: { en_US: 'Email Sender' } },

    services: [
        {
            name: 'send-email',
            script: path.resolve(__dirname, 'src/services/sendEmail.ts'),
            permissions: ['mail:send'],
        },
        {
            name: 'send-bulk',
            script: path.resolve(__dirname, 'src/services/sendBulk.ts'),
            permissions: ['mail:send', 'contacts:read'],
        },
    ],

    scripts: {
        install: path.resolve(__dirname, 'src/scripts/install.ts'),
    },

    install_permissions: ['manage integrations'],
    runtime_permissions: ['mail:send', 'contacts:read'],
}

A public-facing app with its own subdomain. All views and services must be access: "public".

export default {
    name: 'storefront',
    version: '1.0.0',
    type: 'public',
    label: { locales: { en_US: 'Storefront' } },

    views: {
        main: path.resolve(__dirname, 'src/main.tsx'),
    },

    services: [
        {
            name: 'add-to-cart',
            script: path.resolve(__dirname, 'src/services/addToCart.ts'),
            permissions: ['orders:write'],
        },
    ],

    ssrRenderer: path.resolve(__dirname, 'src/ssr/renderer.tsx'),
    runtime_permissions: ['orders:write', 'products:read'],
}

See Also