Skip to content

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:

PartWhy 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().
examplePre-fills the console playground and the reference's request examples. A test checks it passes input.
toPayloadMaps the clean public input to the function's own field names.
parseOutputReads 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.exampleResultThe result shown in the reference's example responses.
maxDurationSecondsThe 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:InvokeFunction statement, and its output prefix to the s3:GetObject statement, in infra/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 .env and .env.example. Override the function name with UTILITY_PDF_TO_IMAGES_FUNCTION if 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 openapi

This 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 test

The 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 readsAn API Gateway proxy event: headers, body as a JSON stringThe invoke payload itself
Auth inside the handlerYes — validates Authorization: Bearer <HS256 JWT>None
What the gateway sendstoPayload(input) JSON-encoded as body, plus a token minted per call (valid for maxDurationSeconds + 5 min) with UTILITY_<SLUG>_JWT_SECRETtoPayload(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

WhereWhat appears
/v1POST /v1/utilities/pdf-to-images (sync) and …/jobs (async), in GET /v1/utilities, with input validation, idempotency, callbacks and rate limits.
Retries & timeoutsThrottling, 502/503 and network failures retried up to JOB_MAX_ATTEMPTS; sync answers 202 after 90 s; the janitor reaps anything interrupted.
StorageEvery file in parseOutput().files is streamed S3 → R2 to pdf-to-images/<date>/<job id>/<name or path>, and callers get R2 links.
ConsoleA catalog card, an overview (KPIs, volume + latency charts, recent jobs), a playground pre-filled with example, and API snippets.
AccessA checkbox in the API-key scope picker — keys can be limited to it.
MetricsIts own row in the dashboard, usage by key, and p50/p95/p99 end-to-end and function latency.
DocsTwo operations in API operations, generated from the definition.

ServiceLabs · a Finnoto company