Appearance
Adding a utility
Putting another function behind the platform is one definition file plus one line in the registry. The /v1 routes, the console (catalog, playground, charts), key scopes, usage metrics and this documentation's API reference all pick it up from the definition.
This guide walks through adding a hypothetical pdf-to-images utility end to end.
The contract: UtilityDefinition
Every utility is described by this interface (apps/server/src/utilities/types.ts):
ts
export interface UtilityDefinition<I = unknown> {
slug: string
name: string
description: string
category: UtilityInfo['category'] // 'documents' | 'archives' | 'spreadsheets'
/** Tabler icon class (e.g. `ti-file-type-pdf`). */
icon: string
/** Default deployed function name; override with UTILITY_<SLUG>_FUNCTION. */
functionName: string
/**
* `apigw`: the handler expects an API Gateway proxy event and does its own
* JWT check, so we wrap the payload and mint a token for it.
* `raw`: the handler reads the invoke payload directly (no auth inside).
*/
event: 'apigw' | 'raw'
/** Resource path on the Lambda's REST API (apigw event path + http transport). */
path: string
maxDurationSeconds: number
input: z.ZodType<I>
example: I
toPayload(input: I): unknown
/** Called only for 2xx upstream responses. Throw to report a malformed upstream body. */
parseOutput(body: unknown): ParsedOutput
/** Feeds the generated OpenAPI reference (docs + /v1/openapi.json). */
docs: UtilityDocs
}
export interface UtilityDocs {
/** A realistic `result` for the reference's example responses. */
exampleResult: { files: ExampleFile[]; [extra: string]: unknown }
/** Utility-specific fields next to `files` (e.g. pageCount), for the result schema. */
resultExtras?: Record<string, { type: 'integer' | 'string'; description: string }>
}
/** A file the function wrote, as it reported it. The storage layer re-hosts it to R2. */
export interface UpstreamFile {
url: string // where the function put it (s3://, https S3 URL, presigned…)
bucket?: string
key?: string
name: string // final file name, used for the R2 key and Content-Disposition
size?: number
contentType?: string
pageNumber?: number
path?: string
}
export interface ParsedOutput {
files: UpstreamFile[]
extras: Record<string, unknown>
}What each part is for:
| Part | Why it matters |
|---|---|
input (zod) | The public contract. Validated at the gateway (→ 400 with per-field details), rendered as the JSON Schema in GET /v1/utilities, the console form and the API reference. Use .strict() and .describe(). |
example | Pre-fills the console playground and the reference's request examples. A test checks it passes input. |
toPayload | Maps the clean public input to the function's own field names. |
parseOutput | Reads the function's exact success body and says which files it produced. Throw (e.g. UpstreamShapeError) if the body isn't what you expect — the job fails with upstream_error instead of returning garbage. |
docs.exampleResult | The result shown in the reference's example responses. |
maxDurationSeconds | The function's own timeout. The gateway waits that long + 30 s. |
1. Write the definition
apps/server/src/utilities/pdf-to-images.ts:
ts
import { z } from 'zod'
import type { UtilityDefinition, UpstreamFile } from './types.js'
import { num, obj, sourceUrl, str, UpstreamShapeError } from './common.js'
const input = z
.object({
url: sourceUrl.describe('The PDF to rasterise (http(s):// or s3://).'),
dpi: z.number().int().min(72).max(300).optional().describe('Resolution (default 150).'),
format: z.enum(['png', 'jpeg']).optional().describe('Image format (default png).'),
})
.strict()
export const pdfToImages: UtilityDefinition<z.infer<typeof input>> = {
slug: 'pdf-to-images',
name: 'PDF → images',
description: 'Renders every page of a PDF to an image.',
category: 'documents',
icon: 'ti-photo',
functionName: 'PdfToImagesFunction',
event: 'apigw', // the handler checks its own HS256 token
path: '/pdf-to-images',
maxDurationSeconds: 300,
input,
example: { url: 'https://example.com/statement.pdf', dpi: 150 },
// Public camelCase → the function's snake_case
toPayload: (i) => ({ pdf_url: i.url, dpi: i.dpi ?? 150, image_format: i.format ?? 'png' }),
// The function answers { success, images: [{ page, s3_key, https_url, size }], bucket }
parseOutput(body) {
const b = obj(body, 'response')
if (!Array.isArray(b.images)) throw new UpstreamShapeError('images is missing')
const files: UpstreamFile[] = b.images.map((raw, i) => {
const img = obj(raw, `images[${i}]`)
const pageNumber = num(img.page) ?? i + 1
return {
url: str(img.https_url, `images[${i}].https_url`),
bucket: typeof b.bucket === 'string' ? b.bucket : undefined,
key: typeof img.s3_key === 'string' ? img.s3_key : undefined,
name: `page-${pageNumber}.png`,
pageNumber,
size: num(img.size),
contentType: 'image/png',
}
})
return { files, extras: { pageCount: files.length } }
},
docs: {
exampleResult: {
pageCount: 1,
files: [
{
url: 'https://files.servicelabs.dev/pdf-to-images/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/page-1.png',
key: 'pdf-to-images/2026-09-10/job_01K7Z3Q4N5P6R7S8T9V0W1X2Y3/page-1.png',
name: 'page-1.png',
size: 204812,
contentType: 'image/png',
pageNumber: 1,
},
],
},
resultExtras: { pageCount: { type: 'integer', description: 'Number of images produced.' } },
},
}2. Register it
Add it to DEFINITIONS in apps/server/src/utilities/definitions.ts — the order there is the console's display order:
ts
import { pdfToImages } from './pdf-to-images.js'
export const DEFINITIONS: UtilityDefinition<any>[] = [
htmlPdf,
splitPdf,
pdfToImages, // ← new
zipFiles,
unzipFiles,
xlsToXlsx,
]3. Wire the deployment
- IAM: add the function's ARN to the
lambda:InvokeFunctionstatement, and its output prefix to thes3:GetObjectstatement, ininfra/aws/gateway-iam-policy.json— then update the gateway user's policy. - Signing key: set
UTILITY_PDF_TO_IMAGES_JWT_SECRET(or_JWT_SECRET_NAME) in.envand.env.example. Override the function name withUTILITY_PDF_TO_IMAGES_FUNCTIONif it differs per environment. See Configuration.
4. Teach the mock runner
Add a case 'pdf-to-images' to apps/server/src/runners/mock.ts that returns the byte-identical success body the real function produces (and writes the files it claims to have written, via the put() helper). Local development and the test suite run against the mock, so this is what exercises your parseOutput and the R2 copy:
ts
case 'pdf-to-images': {
const images = []
for (let page = 1; page <= 2; page++) {
const key = `pdf-to-images/${stamp()}/page-${page}.png`
const https_url = await put(key, Buffer.from('mock png'), 'image/png')
images.push({ page, s3_key: key, https_url, size: 8 })
}
return done(200, { success: true, bucket: config.SOURCE_S3_BUCKET, images })
}5. Regenerate the API reference
bash
npm run openapiThis rewrites apps/docs/public/openapi.yaml from the definitions: two new operations (runPdfToImages, createPdfToImagesJob), the input schema from your zod object, and example requests/responses from example and docs.exampleResult. Commit the file — a test fails if the committed spec doesn't match what the code generates.
Add a page under apps/docs/utilities/ for the prose (limits, behaviour, errors) and link it from the sidebar in apps/docs/.vitepress/config.mts.
6. Test
bash
npm testThe OpenAPI tests check that every utility has one sync and one async operation with unique ids, that example passes your validator, and that the spec matches the committed file. Add integration cases to apps/server/test/api.test.ts for anything specific — a sync run, a validation error, a fail-400 input mapping to 422 upstream_rejected.
apigw vs raw
event: 'apigw' | event: 'raw' | |
|---|---|---|
| The handler reads | An API Gateway proxy event: headers, body as a JSON string | The invoke payload itself |
| Auth inside the handler | Yes — validates Authorization: Bearer <HS256 JWT> | None |
| What the gateway sends | toPayload(input) JSON-encoded as body, plus a token minted per call (valid for maxDurationSeconds + 5 min) with UTILITY_<SLUG>_JWT_SECRET | toPayload(input) as-is |
| Response it expects | { statusCode, body: "<json>" } | Any JSON. { statusCode, body } is unwrapped too; null is treated as a failure |
Functions that were only ever invoked internally are usually raw. Remember that for raw functions the gateway's API keys and IAM are the only protection — which is exactly the point of putting them behind the gateway.
What you get for free
| Where | What appears |
|---|---|
/v1 | POST /v1/utilities/pdf-to-images (sync) and …/jobs (async), in GET /v1/utilities, with input validation, idempotency, callbacks and rate limits. |
| Retries & timeouts | Throttling, 502/503 and network failures retried up to JOB_MAX_ATTEMPTS; sync answers 202 after 90 s; the janitor reaps anything interrupted. |
| Storage | Every file in parseOutput().files is streamed S3 → R2 to pdf-to-images/<date>/<job id>/<name or path>, and callers get R2 links. |
| Console | A catalog card, an overview (KPIs, volume + latency charts, recent jobs), a playground pre-filled with example, and API snippets. |
| Access | A checkbox in the API-key scope picker — keys can be limited to it. |
| Metrics | Its own row in the dashboard, usage by key, and p50/p95/p99 end-to-end and function latency. |
| Docs | Two operations in API operations, generated from the definition. |