Local Development
How to develop and test Forge apps locally using the ptkl toolkit — including views, services, and public views.
Prerequisites
Before starting local development, make sure you have:
- The
ptkltoolkit installed - An active profile pointing at your target environment:
See Profiles for details.
Starting the Dev Server
Run ptkl forge dev from your app directory to start local development:
This starts up to three things depending on your app's configuration:
| Component | Port | What it does |
|---|---|---|
| Vite dev server | 5173 (default) |
Serves your app's view with hot module replacement |
| Service dev server | 4113 |
Executes services locally (only if the app declares services) |
| Dev hub | 4111 |
Relays embeddable view bundles between apps (only if the app has embeddable platform views) |
Open the Vite URL shown in your terminal (e.g. http://localhost:5173) to see your app.
Options
| Option | Description |
|---|---|
-p, --path <path> |
Path to the app directory (required) |
--view <view> |
Which view to serve (defaults to main) |
-m, --mode <mode> |
Vite mode — controls which .env.[mode] file is loaded (defaults to development) |
--env <env> |
Target environment: dev or live (defaults to dev) |
Testing Services Locally
When your app declares services, ptkl forge dev automatically starts a service dev server on port 4113. Services are watch-built — every time you change a service file, it is rebuilt automatically.
Calling a Service
Send requests to http://localhost:4113/{service-name}:
curl -X POST http://localhost:4113/track-invoice \
-H "Content-Type: application/json" \
-d '{"tracking": "INV-2024-001"}'
Listing Available Services
Returns a JSON list of all services declared in your ptkl.config.js with their name, access scope, and permissions.
Service Dev Server Response
The service dev server response wraps the service output with execution logs:
{
"response": {
"statusCode": 200,
"contentType": "application/json",
"body": { "tracking_number": "INV-2024-001", "status": "paid" }
},
"logs": ["Fetched invoice successfully"]
}
Example: Platform App with Services
// ptkl.config.js
export default {
name: 'invoice-app',
version: '1.0.0',
type: 'platform',
views: {
main: path.resolve(__dirname, 'src/main.tsx'),
},
services: [
{
name: 'track-invoice',
script: path.resolve(__dirname, 'src/services/trackInvoice.ts'),
access: 'public',
permissions: ['invoices:read'],
},
{
name: 'create-invoice',
script: path.resolve(__dirname, 'src/services/createInvoice.ts'),
access: 'platform',
permissions: ['invoices:read', 'invoices:write'],
},
],
runtime_permissions: ['invoices:read', 'invoices:write'],
}
# Start dev server — Vite on :5173, services on :4113
ptkl forge dev -p .
# In another terminal:
# List services
curl http://localhost:4113/
# Call the public service
curl -X POST http://localhost:4113/track-invoice \
-H "Content-Type: application/json" \
-d '{"tracking": "INV-2024-001"}'
# Call the platform service
curl -X POST http://localhost:4113/create-invoice \
-H "Content-Type: application/json" \
-d '{"customer_id": "cust_1", "items": [{"name": "Widget", "price": 10, "quantity": 2}]}'
Example: Service App (Headless)
A type: "service" app has no views — only services:
// ptkl.config.js
export default {
name: 'email-sender',
version: '1.0.0',
type: 'service',
services: [
{
name: 'send-email',
script: path.resolve(__dirname, 'src/services/sendEmail.ts'),
permissions: ['mail:send'],
},
],
runtime_permissions: ['mail:send'],
}
# Only the service dev server starts (no Vite, no views)
ptkl forge dev -p .
# Test it
curl -X POST http://localhost:4113/send-email \
-H "Content-Type: application/json" \
-d '{"to": "user@example.com", "subject": "Hello", "body": "Test"}'
Testing Public Views Locally
Public views are standalone single-page applications served without the platform shell — no sidebar, no header, no $forge global. In production, they are accessed at {uuid}.public.ptkl.app/{view-name}.
To develop and test a public view locally, use the --view option:
This starts the Vite dev server with the specified public view's entry point loaded. Open the URL shown in your terminal (e.g. http://localhost:5173) to see the view.
How Public Views Differ in Dev Mode
Platform view (main) |
Public view | |
|---|---|---|
| Shell | Runs inside platform shell with sidebar, header | Standalone — no shell, no $forge |
| Dev Hub | Pushed to dev hub for cross-app embedding | Not pushed to dev hub |
| Service calls | Forge.runService() (@ptkl/sdk/beta) |
Forge.runService() (@ptkl/sdk/beta) |
| Browser URL | http://localhost:5173 |
http://localhost:5173 |
Tip
Since public views don't have $forge, you cannot use $forge.requestPermissions() or other Forge APIs inside them.
Calling Services from a Public View
Always call services through the SDK — never with raw fetch. Forge.runService() resolves your app's UUID, authentication, headers, and dev-vs-production routing for you, so the same view code runs unchanged in both forge dev and production:
// src/publicInvoice.tsx
import { Forge } from '@ptkl/sdk/beta';
const forge = new Forge();
// No UUID needed — the SDK resolves the current app automatically.
const result = await forge.runService('track-invoice', {
body: { tracking: trackingNumber },
// optional: query, headers
});
You never hardcode or look up your app's UUID. A marketplace app is assigned a different UUID in every project it's installed into, so the platform injects the right one at runtime and the SDK reads it for you.
You also do not switch URLs between environments:
- In
forge dev, the toolkit injectsFORGE_DEV_SERVICE_URL(http://localhost:4113) into the view build, sorunService()posts to the local service dev server automatically. - In production, the platform serves the public view with your app's UUID and
API_HOSTset to/{view-name}/api.runService()routes through that path, and the server-side proxy attaches a server-minted forge actor token (scoped to the view's declaredpermissions) before forwarding to the service. The token is never exposed to the browser.
The /{view-name}/api/* proxy is an internal implementation detail the SDK targets — don't call it (or localhost:4113) by hand.
Resolving the UUID is not authorization
runService() only resolves which app to call — it never grants execution. For a platform-access service the caller must hold service::{service} forge::{appUuid}. An app's own services are added to its permission ceiling automatically (you never declare them or hard-code the uuid), but invoking one is still gated by the launching user: a project admin invokes any of the app's services; a non-admin user must have the service::{service} forge::{appUuid} permission assigned to their role (the dashboard's permission catalog exposes it for exactly this). This keeps a privileged service from being callable by every user who merely opens the app. (public-access services require no permission.)
Calling another app's service
runService() takes either a bare service name (the current app, as above) or a service PRN that addresses another app. The rule is simple — no prn: prefix means the current app; a PRN means another app.
You address the other app by name, and declare the permission you need in your manifest, also by name:
// ptkl.config.js
export default {
name: 'lager',
// Ask to call the "gate" service on the "protokol.desk" app. You never need
// that app's per-install UUID — the platform resolves the name at launch.
runtime_permissions: ['service::gate forge::protokol.desk'],
}
App names are the stable identifier: marketplace apps are {company}.{app} (e.g. protokol.desk); custom apps are a bare slug (e.g. desk) and cannot contain a dot. The platform resolves the name to the target's per-install UUID at token-mint time, so this works for marketplace apps whose UUID differs per tenant.
Because the declaration lives in runtime_permissions, it is intersected against the launching user's permissions — a project admin must assign the matching service::{service} forge::{targetUuid} permission to the user's role for a non-admin to invoke the cross-app service. (Cross-app grants intentionally cannot be self-granted via entitlements.)
Example: Platform App with a Public View
// ptkl.config.js
export default {
name: 'invoice-app',
version: '1.0.0',
type: 'platform',
views: {
main: path.resolve(__dirname, 'src/main.tsx'),
'public-invoice': {
entry: path.resolve(__dirname, 'src/publicInvoice.tsx'),
access: 'public',
permissions: ['invoices:read'],
},
},
services: [
{
name: 'track-invoice',
script: path.resolve(__dirname, 'src/services/trackInvoice.ts'),
access: 'public',
permissions: ['invoices:read'],
},
],
runtime_permissions: ['invoices:read'],
}
Development workflow:
# Terminal 1: develop the main platform view
ptkl forge dev -p .
# Terminal 2: develop the public view
ptkl forge dev -p . --view public-invoice
Note
Running two ptkl forge dev instances for the same app serves different views on different ports. The service dev server (port 4113) is shared — both views can call the same services.
Production URLs
Once deployed with ptkl forge bundle --upload, your app's resources are available at:
Views
| Type | Domain | Example |
|---|---|---|
| Platform view | Loaded inside the platform dashboard | — |
| Public view (live) | {uuid}.public.ptkl.app/{view} |
abc-123.public.ptkl.app/public-invoice |
| Public view (dev) | {uuid}.stage.public.ptkl.app/{view} |
abc-123.stage.public.ptkl.app/public-invoice |
Services
| Access | Domain | Example |
|---|---|---|
| Public (live) | {uuid}.public.service.ptkl.app/{service} |
abc-123.public.service.ptkl.app/track-invoice |
| Public (dev) | {uuid}.stage.public.service.ptkl.app/{service} |
abc-123.stage.public.service.ptkl.app/track-invoice |
| Platform (live) | {uuid}.service.ptkl.app/{service} |
abc-123.service.ptkl.app/create-invoice |
| Platform (dev) | {uuid}.stage.service.ptkl.app/{service} |
abc-123.stage.service.ptkl.app/create-invoice |
Your app's UUID is assigned when the app is first created and is visible in the platform UI.
See Also
- App Manifest —
ptkl.config.jsconfiguration reference - Services — service execution model and scripting
- Lifecycle Scripts — install and uninstall scripts
ptkl forge— CLI command reference- Profiles — managing environment profiles