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:
- Receives an HTTP request (body, query, headers)
- Runs in a secure sandboxed environment with platform API access
- 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:
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
- App Manifest —
ptkl.config.jsconfiguration reference - Lifecycle Scripts — install and uninstall scripts
- Security & Trusted Sources — security model for Forge apps