Skip to content

Services

Forge services are server-side scripts that execute in an isolated sandbox and are callable via HTTP. They allow apps to expose API endpoints — either publicly (no authentication) or restricted to platform users.


Overview

A service is a JavaScript function that:

  1. Receives an HTTP request (body, query, headers)
  2. Runs in a secure sandboxed environment with platform API access
  3. Returns an HTTP response

Services are declared in ptkl.config.js and built alongside the app bundle. Once deployed, they are accessible through dedicated service domains.


Defining Services

Services are declared in the services array of ptkl.config.js. How they behave depends on the app's type:

A type: "service" app is headless — no views, no platform shell. It exists purely to expose API endpoints. Services can mix access: "platform" and "public".

// 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'],
        },
        {
            name: 'send-bulk',
            script: path.resolve(__dirname, 'src/services/sendBulk.ts'),
            permissions: ['mail:send', 'contacts:read'],
        },
    ],

    runtime_permissions: [
        'mail:send',
        'contacts:read',
    ],
}

A type: "platform" app can have both views and services. Services can mix access: "platform" (requires authentication) and access: "public" (open to anyone).

// 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',
    ],
}

Each service's permissions must be a subset of the app's runtime_permissions. See the manifest reference for the full field list.


Access Scopes

Scope Authentication Token Use case
public None — anyone can call it App-scoped token minted server-side with the service's declared permissions Public APIs, webhooks, status pages
platform User session or actor token required Caller's token (intersection of caller permissions + service permissions) Internal integrations, admin operations

Public services are not anonymous inside the sandbox

Even though public services require no authentication from the caller, the service script still receives a platform token (if permissions are declared). This token is minted automatically by the platform and is scoped to exactly the permissions listed in the service definition. The caller never sees this token — it is used server-side only.


Service Domains

Services are accessed through UUID-based domains. The app's UUID is assigned when the app is created and is visible in the platform UI.

URL Patterns

Domain Access Environment
{uuid}.service.ptkl.app/{service} Platform (auth required) Live
{uuid}.stage.service.ptkl.app/{service} Platform (auth required) Dev
{uuid}.public.service.ptkl.app/{service} Public (no auth) Live
{uuid}.stage.public.service.ptkl.app/{service} Public (no auth) Dev

Examples

# Public service (live)
POST https://abc-123.public.service.ptkl.app/track-invoice

# Public service (dev/stage)
POST https://abc-123.stage.public.service.ptkl.app/track-invoice

# Platform service (live, requires Authorization header)
POST https://abc-123.service.ptkl.app/create-invoice

# Platform service (dev/stage)
POST https://abc-123.stage.service.ptkl.app/create-invoice

Writing Service Scripts

Service scripts are plain JavaScript or TypeScript files. The build tooling compiles them and the platform executes them in a sandboxed environment (isolated-vm).

TypeScript Support

For full type-checking and autocomplete, import the forge service types in your service script:

import '@ptkl/components/forge-service'

response.status(200).json({ ok: true })
//       ^ autocomplete for status(), json(), html(), xml(), redirect()

This gives you types for all sandbox globals: response, input, body, query, headers, and context.

You can also import individual types for function signatures:

import type { ServiceResponse, ServiceContext } from '@ptkl/components/forge-service'

Services are not views

Service scripts run in an isolated sandbox — there is no browser, no window, no $forge. Use @ptkl/components/forge-service for services, not @ptkl/components/forge-api.

Globals

The following globals are available inside every service script:

Global Type Description
input any Parsed request body
body any Full parsed request body (same as input)
query object URL query parameters
headers object Incoming HTTP request headers
context object Execution context (see below)
response Response Response builder — use to set status, body, and content type
axiosAdapter AxiosAdapter Authenticated HTTP client for platform API calls
$variables Record<string, any> App variables for the current environment (dev or live)

context Object

Field Type Description
context.appUuid string The app's UUID
context.appName string The app's name
context.serviceName string The service being executed
context.executionId string Unique ID for this execution
context.access "public" | "platform" The access scope of this request

response

Every service script must return a response. Use the chainable API to set the status code, content type, and body.

// JSON response (default)
response.status(200).json({ result: "ok" });

// Custom status
response.status(201).json({ id: "inv_123" });

// Error
response.status(404).json({ error: "Invoice not found" });

// HTML
response.status(200).html("<h1>Invoice Paid</h1>");

// XML
response.status(200).xml("<invoice><status>paid</status></invoice>");

// Redirect
response.redirect("/invoices/123");
// → 307 redirect

// Plain send (JSON by default)
response.send({ status: "ok" });

Response Methods

Method Description
.status(code) Set the HTTP status code. Returns response for chaining.
.json(body) Set body as JSON (application/json). Returns response.
.html(body) Set body as HTML (text/html). Returns response.
.xml(body) Set body as XML (text/xml). Returns response.
.send(body) Set body (default application/json). Returns response.
.redirect(url) Set a 307 redirect to the given URL. Returns response.
.setError(err) Attach an error object. Returns response.

Platform API Access

Service scripts have access to the same SDK and HTTP utilities as lifecycle scripts. Import the Protokol SDK to make authenticated platform API calls:

import { Platform } from '@ptkl/sdk'

const platform = new Platform()

const invoices = await platform.component("invoices").find({
    filter: { tracking_number: input.tracking },
});

response.json(invoices.data);

All sandbox libraries are also available: moment, lodash, Handlebars, bcrypt, crypto, jwt, jexl, the Protokol SDK, and the full set of utility libraries listed below.


Available Libraries

All libraries are pre-loaded and available as globals inside every service script.

Core Utilities

Global Library Description
moment Moment.js Date/time manipulation
_ Lodash Utility functions
Handlebars Handlebars Template engine
Buffer Node.js Buffer Binary data operations

Security & Encoding

Global Description
bcrypt Password hashing — genSalt, hash, compare
crypto Cryptographic operations — hash, hmac, encrypt, decrypt, sign, verify
jwt JSON Web Tokens — sign, verify, decode

Data Validation & Transformation

Global Library Description
zod Zod Schema validation (zod.z.string(), zod.z.object(), etc.)
validator validator.js String validation — isEmail, isURL, isUUID, etc.
Decimal decimal.js Arbitrary-precision decimal arithmetic
Qs qs Query string parsing and stringifying
objectHash object-hash Deterministic object hashing

Date & Identifiers

Global Description
dayjs Day.js — lightweight date library
uuid UUID generation — uuid.v4(), uuid.v5(name, ns), uuid.validate(str)
nanoid Nano ID generation — nanoid.generate(size), nanoid.custom(alphabet, size)
slugify slugify — generate URL-safe slugs

Parsing & Serialization

Global Library Description
fxp fast-xml-parser XML parsing and building — new fxp.XMLParser(), new fxp.XMLBuilder()
jsyaml js-yaml YAML parsing — jsyaml.load(), jsyaml.dump()
pako pako Zlib compression — pako.deflate(), pako.inflate()
he he HTML entity encoding/decoding — he.encode(), he.decode()
mime mime-types MIME type lookup — mime.lookup('file.pdf'), mime.extension('application/json')

Text & Markup

Global Library Description
marked marked Markdown to HTML conversion
sanitizeHtml sanitize-html HTML sanitization

Visual Output

Global Library Description
QRCode qrcode QR code generation (data URI, SVG, text)
bwipjs bwip-js Barcode generation (100+ symbologies)

Authentication

Global Description
otp OTP/2FA — otp.generateSecret(), otp.generateToken(secret), otp.verifyToken(token, secret)
jexl JEXL — JavaScript expression language

SDK

The Protokol SDK is available for making authenticated platform API calls. See Platform API Access.


Examples

Public Service — Track Invoice

A public endpoint that looks up an invoice by tracking number. No authentication required from the caller.

// src/services/trackInvoice.ts
import { Platform } from '@ptkl/sdk'

const platform = new Platform()
const tracking = input.tracking || query.tracking;

if (!tracking) {
    response.status(400).json({ error: "tracking parameter required" });
}

const result = await platform.component("invoices").find({
    filter: { tracking_number: tracking },
    perPage: 1,
});

const invoice = result.data?.[0];

if (!invoice) {
    response.status(404).json({ error: "Invoice not found" });
}

response.json({
    tracking_number: invoice.tracking_number,
    status: invoice.status,
    amount: invoice.total_amount,
    currency: invoice.currency,
    updated_at: invoice.updated_at,
});

Config:

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

Call it:

curl -X POST https://abc-123.public.service.ptkl.app/track-invoice \
  -H "Content-Type: application/json" \
  -d '{"tracking": "INV-2024-001"}'

Platform Service — Create Invoice

An authenticated endpoint that creates a new invoice. Requires the caller to have invoices:write permission.

// src/services/createInvoice.ts
import { Platform } from '@ptkl/sdk'

const platform = new Platform()
const { customer_id, items, currency } = input;

if (!customer_id || !items || !items.length) {
    response.status(400).json({ error: "customer_id and items are required" });
}

const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);

const result = await platform.component("invoices").create({
    customer_id,
    items,
    total_amount: total,
    currency: currency || 'EUR',
    status: 'draft',
});

response.status(201).json(result.data);

Config:

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

HTML Response

Services can return non-JSON content types:

// src/services/invoicePage.ts
import { Platform } from '@ptkl/sdk'

const platform = new Platform()

const result = await platform.component("invoices").find({
    filter: { tracking_number: input.tracking },
    perPage: 1,
});

const data = result.data?.[0];

if (!data) {
    response.status(404).html("<h1>Invoice not found</h1>");
}

response.html(`
    <h1>Invoice ${data.tracking_number}</h1>
    <p>Status: ${data.status}</p>
    <p>Amount: ${data.total_amount} ${data.currency}</p>
`);

Adapting Behavior by Access Scope

A single service can check context.access to adapt its behavior:

if (context.access === 'public') {
    response.json({ status: invoice.status });
} else {
    response.json(invoice);
}

Execution Limits

Parameter Value
Timeout 5 minutes (300,000 ms)
Memory 128 MB per execution
Script size Max 5 MB
Input payload Max 1 MB
Output payload Max 5 MB

Token Caching

For public services with permissions, the platform mints and caches forge actor tokens server-side:

  • Tokens are minted via the platform's auth service with the service's declared permissions
  • Cached in Redis with key forge:token:{appUuid}:{env}:{version}:{serviceName}
  • Cache TTL: 10 minutes (token TTL: 15 minutes)
  • Deploying a new version automatically invalidates cached tokens (version is part of the cache key)
  • Tokens are never exposed to the caller — they exist only server-side

Viewing Services in the UI

When an app has registered services, they appear in the Services tab on the app's edit page in the platform UI. The tab shows:

  • Service name and access scope badge (public/platform)
  • Declared permissions
  • Script path
  • Public endpoint URL (for public services)

See Also