Cat Factory
Home
Get Started
GitHub
Home
Get Started
GitHub
  • Start

    • Introduction
    • Core Concepts
    • Quick Start
    • Tutorial: Your First Task to a Merged Pull Request
  • Guides

    • Recipes

      • Cookbook
    • Plan the work

      • Design Your Board
      • Clarify Requirements
      • Author a Document
      • Plan an Initiative
    • Run pipelines

      • Choose and Edit a Pipeline
      • Run a Pipeline
      • Schedule Recurring Work
      • Review and Merge Pull Requests
      • Control Spend with Budgets
    • Connect

      • Connect a Repository
      • Connect Issue & Document Sources
      • Feed Design Context to Agents
      • Preview and Test a Frontend
    • Models & prompts

      • Connect a Model Provider
      • Apply Standards with Prompt Fragments
      • Run a Claude Skill as a Step
      • Compare Prompts and Models in the Sandbox
    • Collaborate

      • Invite and Manage Your Team
      • Share Services Across Workspaces
      • Register Foundational Services
  • Deploy

    • Run Locally
    • Deploy to Node.js
    • Deploy to Cloudflare
    • Deploy on Kubernetes
    • Lay Out a Kubernetes Cluster
    • Set Up a Local Kubernetes Cluster on Windows
    • Register the GitHub App
    • Set Up Enterprise SSO
    • Set Up Your Deployment Repository
    • Configuration
  • Operate

    • Observability
    • Set Up Notifications
    • Run Jobs on Your Own Runners
    • Provision Ephemeral Environments
    • Debug a Run from Outside the Browser
    • Troubleshooting
    • Upgrades & Data Retention
  • Extend

    • Add a Custom Agent Kind
    • Add a Custom Gate or Judge
    • Add a Custom Provider
    • Extend the App with Frontend Modules
    • Integration Manifests
    • Give Agents External Tools (MCP)
    • Package a Reusable Operation
    • Register an Initiative Preset
    • Public API
    • Official SDKs
    • MCP Server
    • Cloudflare OS Gatekeeper
  • Reference

    • Architecture
    • Agent Isolation Model
    • Security Model & Hardening
    • Packages & Repository Layout
    • GitHub and GitLab Support Matrix
    • Environment Variables
    • API Endpoint Reference
    • Glossary

Add a Custom Provider

For the deployment whose infrastructure a manifest cannot describe. Most deployments connect what they own (a preview-environment platform or a runner scheduler) with a declarative manifest, and a single generic adapter drives it over HTTP. No code. Read this page only once that has run out.

The manifest isn't enough when your platform speaks a protocol that doesn't map cleanly onto request/response templates, needs multi-step orchestration per operation, talks gRPC or a vendor SDK, or derives values (a URL, a status) from a response shape too dynamic for dot-paths. For those cases the backend exposes the same ports the built-in adapters implement as code seams, so you can ship your own adapter inside your deployment repository without forking the platform.

This page shows how to implement and wire a custom environment provider and a custom runner pool, with a realistic (vendor-neutral) example for each, and the gotchas that bite.

Reach for the manifest first

A code adapter is more to own and maintain. If your platform can be driven over plain HTTP, the manifest path is the supported default for almost every deployment. Use code when the manifest genuinely can't express the integration.

The three ports

PortPackageMethodsBacked by, by default
EnvironmentProvider@cat-factory/kernelprovision, status, teardownthe manifest-driven HTTP environment adapter
RunnerPoolProvider@cat-factory/kerneldispatch, poll, releasethe manifest-driven HTTP runner adapter
BinaryBlobBackend@cat-factory/kernelput, get, deleteone of the five shipped stores (fs, db, s3, r2, memory)

You implement the port as a class, then inject it when you build the container. Your implementation replaces the default adapter for that capability; everything else (the deployer/tester agents, the durable execution worker, the TTL sweeper) is unchanged.

How configuration reaches your adapter

A code adapter is a deployment-wide singleton: one instance serves every workspace. So it must not bake in per-workspace settings. Two channels carry configuration:

  1. Deployment-wide defaults, read once from the environment when you construct the adapter (base URL fallback, a service-account identity, timeouts).
  2. Per-workspace settings, which arrive on every call as the connection manifest. Each workspace registers a connection (the same registration the manifest path uses), and your adapter reads:
    • manifest.baseUrl, your platform's API root for that workspace;
    • resolveSecret(key), the per-workspace credential, decrypted in memory only at call time and never logged;
    • manifest.providerConfig, an opaque key/value bag for native-adapter settings the generic manifest has no field for (a project name, a target service, status overrides, and so on).

providerConfig is your structured config slot

The manifest models the generic HTTP adapter (URL, templates, auth, response mapping). Anything a native adapter needs beyond that goes in providerConfig: validate and read it yourself; the HTTP adapter ignores it. This is what lets one deployment serve many workspaces with different projects or targets. See Integration Manifests.

A small helper that overlays the manifest onto the env defaults keeps each method clean:

import type { EnvironmentManifest, SecretResolver } from '@cat-factory/kernel'

interface Defaults {
  baseUrl?: string
  secretKey: string        // which connection secret holds the token
  timeoutMs: number
}

interface Effective {
  baseUrl: string
  token: string
  project?: string
  statusOverrides: Record<string, string>
}

function resolve(defaults: Defaults, manifest: EnvironmentManifest, resolveSecret: SecretResolver): Effective {
  const pc = (manifest.providerConfig ?? {}) as Record<string, unknown>
  const baseUrl = manifest.baseUrl?.trim() || defaults.baseUrl
  if (!baseUrl) throw new Error('preview platform: no base URL on the connection or in env')

  const token = resolveSecret(defaults.secretKey)
  if (!token) throw new Error(`preview platform: no secret '${defaults.secretKey}' on the connection`)

  return {
    baseUrl: baseUrl.replace(/\/+$/, ''),
    token,
    project: typeof pc.project === 'string' ? pc.project : undefined,
    statusOverrides: (pc.statusMap as Record<string, string>) ?? {},
  }
}

Letting the UI configure your adapter

The three lifecycle methods are all an adapter must implement. Implement three more optional methods and your native adapter gets a generated connect form under Settings → Integrations, a connection test before save, and a place in the unconfigured-provider banner, instead of asking operators to hand-author the connection manifest.

MethodReturnsWhat it gives you
describeConfig(manifest?)ProviderConfigField[]The fields the connect form renders, one per config value you need.
describeManifestTemplate()EnvironmentManifest / RunnerPoolManifestThe base manifest the SPA overlays the flat form values onto, so storage stays one manifest.
testConnection(req)Promise<ConnectionTestResult>A probe the UI calls before save; { ok, message }, never throws to the client.

Each ProviderConfigField is one form field:

import type { ProviderConfigField } from '@cat-factory/kernel'

describeConfig(): ProviderConfigField[] {
  return [
    { key: 'baseUrl', label: 'API base URL', required: true, placeholder: 'https://preview.internal' },
    { key: 'apiToken', label: 'API token', secret: true, required: true },
    { key: 'project', label: 'Project', help: 'Target project for this workspace' },
    { key: 'region', label: 'Region', type: 'select',
      options: [{ value: 'eu', label: 'EU' }, { value: 'us', label: 'US' }], default: 'eu' },
  ]
}

Field rules that matter:

  • secret: true renders a password input, never echoes the value back, and routes it into the encrypted secret bundle. A secret field never has a default and must be re-entered on every re-save (the form notes this); a missing secret on re-save is a loud register-time error, not a silent loss.
  • default makes a field optional in practice: a blank input falls back to the default, and the form shows a "defaulted to …" hint. Never set default on a secret.
  • required without a default and with no stored value yet is what drives the banner (below).

How the flat form becomes one stored manifest

A native adapter is configured through flat fields but stored as a single manifest, so describeManifestTemplate() returns the scaffold the SPA overlays those fields onto:

  • a secret field goes into the secret bundle (your scaffold's auth already references its key),
  • a non-secret field goes into providerConfig[key],
  • a field named baseUrl goes onto the manifest's baseUrl.

The scaffold supplies the parts no flat field carries: the auth scheme, the request templates (a native adapter ignores them at run time but the manifest schema requires them), and the response mapping. It carries no secret values, only the shape and the secret-ref key names. On re-save the form overlays edits onto the connection's current saved manifest (not a bare scaffold), so the existing stored providerConfig, including nested values the flat form does not render, stays preserved.

If you skip these methods, the provider falls back to the hand-authored manifest editor: the adapter still works, operators just configure it as a manifest by hand.

The unconfigured-provider banner

When a provider is wired into the container but a workspace has not supplied every required field that has no default, Cat Factory shows a loud banner in Settings → Integrations listing exactly what is missing. The signal is computed server-side from the stored secret bundle plus the manifest's providerConfig and baseUrl, so the connect form, the banner, and register-time validation read one source of truth. The banner clears itself once the last missing field is supplied. A required field that you back with a default never triggers the banner.

Connection test

If you implement testConnection, the connect form gains a test button that runs before save. The request carries the candidate config (config for a native adapter, the candidate manifest for a manifest provider) and a resolveSecret over the unpersisted secret values, so you can probe the real endpoint with the about-to-be-saved credentials:

async testConnection(req: EnvironmentConnectionTestRequest): Promise<ConnectionTestResult> {
  try {
    const cfg = resolve(this.defaults, req.manifest ?? {} as any, req.resolveSecret)
    const res = await this.call(cfg, 'GET', '/v1/health')
    return { ok: true, message: `reached ${cfg.baseUrl}` }
  } catch (e) {
    return { ok: false, message: e instanceof Error ? e.message : 'unreachable' }
  }
}

Return { ok: false, message } for a failure rather than throwing; the message is shown to the operator. Omit the method entirely and the form shows nothing to test.

Example: a custom environment provider

Say your org runs an internal Preview Platform with a REST API the generic manifest can't quite model: creation is asynchronous, the live URL lives in a endpoints[] array you must filter by health, and its status vocabulary is its own.

Imagine its API as:

CallReturns
POST /v1/previews { ref, repo, ttl_seconds }202 { id, state: "queued" }
GET /v1/previews/{id}{ id, state, endpoints: [{ name, url, healthy }] }
DELETE /v1/previews/{id}204 (or 404 if already gone)

…where state is one of queued | booting | running | degraded | terminated | error.

Implement the port

import type {
  EnvironmentProvider,
  EnvironmentStatus,
  ProvisionEnvironmentRequest,
  EnvironmentStatusRequest,
  EnvironmentTeardownRequest,
  ProvisionedEnvironment,
} from '@cat-factory/kernel'

// Map the platform's vocabulary onto Cat Factory's lifecycle states. The set of incoming
// values is closed and small, so enumerate it and treat anything unexpected as a failure
// rather than silently looping in "provisioning".
const STATE_MAP: Record<string, EnvironmentStatus> = {
  queued: 'provisioning',
  booting: 'provisioning',
  running: 'ready',
  degraded: 'ready',        // reachable; let the tester decide
  terminated: 'torn_down',
  error: 'failed',
}

export class PreviewPlatformProvider implements EnvironmentProvider {
  constructor(private readonly defaults: Defaults, private readonly fetchImpl: typeof fetch = fetch) {}

  async provision(req: ProvisionEnvironmentRequest): Promise<ProvisionedEnvironment> {
    const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
    const ctx = req.provisionContext

    // Build the ref from the typed context (PR number preferred, else branch). `inputs`
    // carries the same values as strings if you'd rather template them.
    const ref = ctx?.pullNumber != null ? `pr/${ctx.pullNumber}` : ctx?.branch
    if (!ref) throw new Error('preview platform: no git ref to provision from')

    const res = await this.call(cfg, 'POST', '/v1/previews', {
      ref,
      repo: ctx?.repoName,
      ttl_seconds: ttlSeconds(req.manifest),
    })

    // Creation is async: no URL yet. Report `provisioning`; the URL appears on a later poll.
    return this.toHandle(cfg, res, 'provisioning')
  }

  async status(req: EnvironmentStatusRequest): Promise<ProvisionedEnvironment> {
    const id = req.externalId ?? req.provisionFields.externalId
    if (!id) {
      return { externalId: null, url: req.provisionFields.url ?? null, status: 'provisioning', expiresAt: null, access: null, fields: req.provisionFields }
    }
    const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
    const res = await this.call(cfg, 'GET', `/v1/previews/${encodeURIComponent(id)}`)
    if (res === null) {
      // 404 after provision: the platform no longer knows this env, so treat as gone.
      return { externalId: id, url: null, status: 'torn_down', expiresAt: null, access: null, fields: req.provisionFields }
    }
    return this.toHandle(cfg, res, 'provisioning')
  }

  async teardown(req: EnvironmentTeardownRequest): Promise<{ status: EnvironmentStatus }> {
    const id = req.externalId ?? req.provisionFields.externalId
    if (id) {
      const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
      // Idempotent: deleting an already-gone env is success, not an error.
      await this.call(cfg, 'DELETE', `/v1/previews/${encodeURIComponent(id)}`)
    }
    return { status: 'torn_down' }
  }

  // --- helpers -------------------------------------------------------------

  private toHandle(cfg: Effective, body: any, fallback: EnvironmentStatus): ProvisionedEnvironment {
    const externalId: string | null = body.id ?? null
    const status = STATE_MAP[(cfg.statusOverrides[body.state] ?? body.state)] ?? fallback
    // Pull the live URL out of the endpoints array, preferring a healthy one.
    const endpoints: Array<{ url?: string; healthy?: boolean }> = body.endpoints ?? []
    const url = (endpoints.find((e) => e.healthy)?.url ?? endpoints[0]?.url) ?? null
    const fields: Record<string, string> = {}
    if (externalId) fields.externalId = externalId
    if (url) fields.url = url
    return { externalId, url, status, expiresAt: null, access: null, fields }
  }

  private async call(cfg: Effective, method: string, path: string, body?: unknown) {
    const res = await this.fetchImpl(`${cfg.baseUrl}${path}`, {
      method,
      headers: { authorization: `Bearer ${cfg.token}`, 'content-type': 'application/json', accept: 'application/json' },
      body: body !== undefined ? JSON.stringify(body) : undefined,
      signal: AbortSignal.timeout(this.defaults.timeoutMs),
    })
    if (res.status === 404) return null            // caller decides what a 404 means
    if (!res.ok) throw new Error(`preview platform ${method} ${path} → ${res.status}`)
    if (res.status === 204) return {}
    return res.json()
  }
}

function ttlSeconds(manifest: EnvironmentManifest): number | undefined {
  return manifest.defaultTtlMs ? Math.floor(manifest.defaultTtlMs / 1000) : undefined
}

The lifecycle contract

MethodWhen it's calledReturn
provisionThe deployer agent, once.A handle. Async platforms return provisioning; the URL can be null here.
statusPolled until the env is ready (or fails / times out).The current handle. Map your platform's status; surface the URL once it exists.
teardownOn run completion or TTL expiry.{ status: 'torn_down' }. Must be idempotent.

provisionContext gives you typed git/PR/repo facts (branch, pullNumber, repoOwner, repoName, pullUrl), and the same values are mirrored into inputs as strings. fields you return from provision are persisted and handed back to status/teardown as provisionFields, so stash anything you need to re-address the environment (its id, a region, a sub-resource).

Proving a teardown

A fourth, optional method is the difference between a reported reclaim and a proven one. Nothing reads your teardown() returning cleanly as the environment's death, so a backend that does not implement this has every teardown recorded as unverifiable, and a deployment counting on auto-teardown to control spend has no evidence it happened:

confirmTeardown?(req: EnvironmentTeardownRequest): Promise<TeardownProbe>

type TeardownProbe =
  | { state: 'gone' }                                          // the ONLY answer that proves one
  | { state: 'present'; terminating: boolean; detail?: string }
  | { state: 'unknown'; reason: string; retryable: boolean }
  • Under-claim. Anything you cannot establish is unknown, never gone. A 404 from a misconfigured base URL and a 404 from a reclaimed environment are the same response; if your adapter cannot tell them apart, say so. The signal exists to be trusted, so cautious is the only safe direction to be wrong in.
  • terminating and retryable decide whether anyone should wait. A resource draining its finalizers confirms on a later pass; one that is simply still there never will. A transient outage (retryable: true) is worth re-probing; a permanent inability to verify answers identically forever and is only ever fixed by a person.
  • Do not answer out of status() instead. You wrote status() to describe a LIVE environment, so what it says about a destroyed one is incidental. The generic manifest provider with no status: template returns ready forever, which as a teardown verdict is a confident lie in the worst direction.

The probe is bounded in wall-clock time (it is awaited inline on an on-demand teardown and on the TTL sweep), so an unresponsive one costs the confirmation and never the teardown itself.

Wire it in

A provider is not injected as a deployment-wide singleton. You register a backend under a kind of your own, and a workspace selects that kind when it connects. That is what lets one deployment serve two workspaces on different platforms, and it is the same registry the built-in manifest and kubernetes backends register on.

The backend is a small wrapper around the provider you just wrote. It answers the questions the platform asks before a run: what to call your kind, which config keys are secrets, how a connect config maps to and from the stored manifest, whether a config is safe to accept, which infra engines you serve, and how to build the live provider:

// deploy/shared/src/preview-backend.ts  (a plain value, NOT a side-effect import)
import type { EnvironmentBackendProvider } from '@cat-factory/integrations'
import { PreviewPlatformProvider } from '@your-org/preview-provider'

export const previewEnvironmentBackend: EnvironmentBackendProvider = {
  kind: 'acme-preview',
  displayLabel: 'Acme Preview',
  engines: () => ['remote-custom'],
  referencedSecretKeys: () => ['preview_token'],
  connectionMeta: (config) => ({
    providerId: 'acme-preview',
    label: 'Acme Preview',
    baseUrl: config.baseUrl,
  }),
  assertConfigSafe: (config, opts) => assertUrlSafe(config.baseUrl, opts?.urlPolicy),
  toManifest: (config) => buildManifest(config),
  fromManifest: (manifest) => readConfig(manifest),
  buildProvider: (ctx) =>
    new PreviewPlatformProvider({ secretKey: 'preview_token', timeoutMs: 15_000, urlPolicy: ctx.urlPolicy }),
}

Then register it by reference on the registry bundle and hand that bundle to the facade. Both runtimes take the same bundle, because an environment backend and its runner backend are two halves of one deployment's infrastructure:

// deploy/backend/src/main.ts  (Node service)
import { start, buildNodeContainer, createBackendRegistries } from '@cat-factory/node-server'
import { previewEnvironmentBackend } from '../../shared/src/preview-backend'

const backendRegistries = createBackendRegistries()
backendRegistries.environmentBackendRegistry.register(previewEnvironmentBackend)
// A `remote-custom` backend also needs a manifest type in the catalog, or no service can pin it.
backendRegistries.customManifestTypeRegistry.register({ manifestId: 'acme-preview', label: 'Acme Preview' })

start({
  buildContainer: (opts) => buildNodeContainer({ ...opts, backendRegistries }),
}).catch((err) => { console.error(err); process.exit(1) })
// deploy/local/src/main.ts  (local mode)
import { startLocal, createBackendRegistries } from '@cat-factory/local-server'
import { previewEnvironmentBackend } from '../../shared/src/preview-backend'

const backendRegistries = createBackendRegistries()
backendRegistries.environmentBackendRegistry.register(previewEnvironmentBackend)

startLocal({ backendRegistries }).catch((err) => { console.error(err); process.exit(1) })

startLocal({ backendRegistries }) keeps all of local mode's boot behaviour (container-runtime preflight, orphan reaping, PAT/auth warnings) and just threads your registrations through, so the local entry stays a one-liner. On local it is its own boot option; on Node it rides buildNodeContainer, which start calls for you.

Register by reference, and register on every process

createBackendRegistries() news the instance; register mutates that instance. Never import a registry from @cat-factory/kernel or news a second one to register into, because a workspace:* dependency publishes as an exact version, so a consumer floating the range can resolve two physical copies and your registration lands in the one nothing reads. A mothership deployment is two processes, so it registers in both entry points.

Registering the backend teaches the platform how a custom environment is stood up. It does not by itself let a service choose one: a service pins a manifestId from the custom-manifest-type catalog, and a remote-custom backend declares which ids it accepts (acceptsManifestIds, or none to accept any). Register the backend and leave that catalog empty, and the service inspector's provisioning picker offers nothing, which reads as the backend not being registered at all.

The environments module still needs to be enabled (ENVIRONMENTS_ENABLED=true + an encryption key) and each workspace registers a connection selecting your kind. That connection is what holds the sealed token and the providerConfig. That requirement is intentional.

Implement confirmTeardown too, or your environments are reclaimed and never proven reclaimed: nothing reads a clean teardown() as the environment's death, so a backend without the probe has every teardown recorded as unverifiable. See the teardown probe.

Teaching the detector to recognise your repos

When someone adds a service from a repo, Cat Factory auto-detects a recommended provisioning config. Out of the box it knows Kubernetes and Docker Compose layouts; your provider can teach it its own shape. Add a detect() hook to the custom manifest type you register on the CustomManifestTypeRegistry:

import { joinRepoPath, matchManifestSignature, readYamlDoc } from '@cat-factory/kernel'
import type { CustomManifestDetection, CustomManifestDetectionContext } from '@cat-factory/kernel'

registry.register({
  manifestId: 'stack-deploy',
  label: 'Stack deploy',
  defaultManifestPath: 'deploy/stack.yml',
  async detect(ctx: CustomManifestDetectionContext): Promise<CustomManifestDetection | null> {
    // A multi-file signature is what makes a match trustworthy: one common filename isn't enough.
    const signature = await matchManifestSignature(
      ctx.scanner,
      {
        required: ['deploy/stack.yml', 'deploy/up.sh', 'deploy/compose.yml'],
        optional: ['deploy/ingress.conf'],   // corroborating; raises confidence, never required
      },
      ctx.directory ? { root: ctx.directory } : {},
    )
    if (!signature.matched) return null      // not my provider; arbitration skips me

    const manifestPath = joinRepoPath(ctx.directory, 'deploy/stack.yml')
    const manifest = await readYamlDoc<StackManifest>(ctx.scanner, manifestPath)

    return {
      matched: true,
      confidence: signature.confidence,
      manifestPath,
      secondaryPaths: signature.matchedPaths,
      // Prefill the confirm form from what you just read.
      configSeed: [{ key: 'healthPort', value: String(manifest?.deploy?.health?.port ?? '') }],
      notes: [{ field: 'provisionType', confidence: signature.confidence, message: 'Detected a stack-deploy repo.' }],
    }
  },
})

The hook receives a budget-bounded, checkout-free scanner: no clone, no host daemon, and a shared read budget across every provider in the sweep. Compose the probe primitives from @cat-factory/kernel against it, matchManifestSignature, firstPresent, readYamlDoc, and listFiles, so a provider package can author detection with nothing but @cat-factory/kernel and the registry type.

Returning null (or matched: false) means "not my provider". When several providers match, the detector arbitrates on confidence and takes the best. The sweep runs last, after the built-in Kubernetes and Compose detection, so a repo those already recognise is untouched. What you return becomes a non-binding recommendation: manifestPath prefills the service's manifest path, configSeed prefills the form, secondaryPaths and notes explain the match. Someone still accepts or changes it. A provider with no detect() falls back to resolving defaultManifestPath by path alone, exactly as before.

Example: a custom runner pool

The runner port is the same idea for where coding jobs run. The platform dispatches a job, polls it to completion, and (optionally) releases it; your adapter maps your scheduler onto that.

Implement RunnerPoolProvider:

import type {
  RunnerPoolProvider,
  RunnerDispatchRequest,
  RunnerPollRequest,
  RunnerJobView,
} from '@cat-factory/kernel'

const STATE: Record<string, RunnerJobView['state']> = {
  queued: 'running',
  running: 'running',
  succeeded: 'done',
  failed: 'failed',
}

export class SchedulerRunnerPool implements RunnerPoolProvider {
  constructor(private readonly defaults: Defaults, private readonly fetchImpl: typeof fetch = fetch) {}

  // Start (or re-attach to) the job. MUST be idempotent per jobId: a replayed dispatch
  // must not launch a duplicate. Key your scheduler on req.jobId.
  async dispatch(req: RunnerDispatchRequest): Promise<void> {
    const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
    await this.call(cfg, 'PUT', `/jobs/${encodeURIComponent(req.jobId)}`, { payload: req.spec })
  }

  // Read current state, mapped onto the canonical view. Carry the work product (PR URL,
  // branch, summary) through on completion so the platform can open the pull request.
  async poll(req: RunnerPollRequest): Promise<RunnerJobView> {
    const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
    const job = await this.call(cfg, 'GET', `/jobs/${encodeURIComponent(req.jobId)}`)
    const state = STATE[job.state] ?? 'running'
    return {
      state,
      ...(state === 'done' ? { result: { prUrl: job.pr_url, branch: job.branch, summary: job.summary } } : {}),
      ...(state === 'failed' ? { error: job.error ?? 'job failed' } : {}),
    }
  }

  // Best-effort, idempotent: release a finished job's resources. A no-op is fine.
  async release(req: RunnerPollRequest): Promise<void> {
    const cfg = resolve(this.defaults, req.manifest, req.resolveSecret)
    await this.call(cfg, 'DELETE', `/jobs/${encodeURIComponent(req.jobId)}`)
  }

  private async call(cfg: Effective, method: string, path: string, body?: unknown) { /* as above */ }
}

What your scheduler runs is not your concern to define: every job is the standard executor-harness container image (the same payload Cloudflare Containers run). Your scheduler pulls that image, runs it, and exposes its job lifecycle; the harness does the coding, the Git operations, and produces the branch the platform opens a PR from. The job spec you receive in dispatch is opaque: forward it to the harness verbatim.

A custom runner pool is wired through the Node runtime's runner-transport seam (the same one the manifest adapter uses), keyed per workspace from the registered connection. Because that plumbing is more involved than the environment seam, see backend/docs/runner-pool-integration.md in the source repo for the exact wiring, and prefer the manifest path unless your scheduler truly can't be driven over HTTP.

Example: a custom binary artifact store

The platform's binary artifacts (the UI Tester's screenshots and the reference designs they are compared against) ship with five backends: local filesystem, the database, S3, R2, and an in-memory store for tests. Put the bytes anywhere else (Google Cloud Storage, Azure Blob, an internal object service) by registering a BinaryBlobBackend in your deployment repository.

import { defaultBinaryStoreRegistry, type BinaryBlobBackend } from '@cat-factory/node-server'

const binaryStoreRegistry = defaultBinaryStoreRegistry()

binaryStoreRegistry.register({
  id: 'gcs',
  name: 'Cloud Storage',
  summary: 'The org bucket (europe-west1).',
  create: ({ accountId }) => new GcsBlobBackend({ bucket: process.env.GCS_BUCKET, accountId }),
})

await start({ binaryStoreRegistry })

Import the seam from the facade you already depend on, and note four rules:

  • A store holds only bytes. Artifact metadata always stays in the runtime's own database, so it is listed, joined, and pruned like any other row.
  • Registering only offers the store. An account selects it in the deployment settings panel, where each registered store appears beside the built-in ones. An account pointed at a store this build does not register resolves to no storage and says so in the settings panel, in the settings summary, and in a warning log, rather than failing silently at the first screenshot.
  • The registered id is stamped onto every artifact row, so it has to stay stable across releases: it is what says which store to ask for those bytes later.
  • Implement delete. The retention sweep, the re-import reclaim, and the workspace purge all delete bytes before dropping metadata rows. A delete that throws keeps its metadata row so a later sweep retries, rather than orphaning the bytes.

create may return null for "this deployment cannot serve the store right now" (an unset credential, an un-provisioned bucket). The resolver then reads as storage-unavailable, the same as a backend a runtime does not support, and logs which store declined. It is called once per account on a cache miss and memoised until that account's storage config changes, so the client you build there survives across requests.

Register on every process that HANDLES the bytes

A store is a live client holding credentials, so it cannot be shared over a machine API the way a registered generator can: the process holding the bytes is the one that has to construct it. A standalone deployment is one process and one registration. A mothership deployment is two: each node writes its artifacts through its own registry, and the mothership runs the artifact-retention sweep, which deletes through its registry. Register on startLocal({ binaryStoreRegistry }) and on the mothership's start({ binaryStoreRegistry }). Registering only on the nodes writes the bytes and never reclaims them, and the sweep then reports the same zero it reports for a deployment that stores nothing.

The full contract, including how the per-account cache is keyed, is in backend/docs/custom-binary-stores.md.

Testing your adapter

Inject fetch, and your adapter is a pure unit under test, with no network and no platform:

import { describe, it, expect } from 'vitest'

it('reports ready and the healthy URL on a status poll', async () => {
  const fetchImpl = async () =>
    new Response(JSON.stringify({ id: 'p1', state: 'running', endpoints: [{ url: 'https://p1.preview.test', healthy: true }] }), { status: 200 })
  const provider = new PreviewPlatformProvider({ secretKey: 'preview_token', timeoutMs: 5000 }, fetchImpl as typeof fetch)

  const handle = await provider.status({
    manifest: { baseUrl: 'https://preview.test', providerConfig: {} } as any,
    externalId: 'p1',
    provisionFields: { externalId: 'p1' },
    resolveSecret: (k) => (k === 'preview_token' ? 'secret' : undefined),
  })

  expect(handle.status).toBe('ready')
  expect(handle.url).toBe('https://p1.preview.test')
})

Cover the mapping seams explicitly: every status value (including an unexpected one), the URL selection, a 404 on status/teardown, and a missing token.

Gotchas

These are the ones that actually bite when adapting a real platform:

  • Enumerate the status vocabulary; don't guess. Map the platform's complete, real set of states onto the lifecycle. Treat an unknown value as failed: an unknown status usually means the contract changed, and silently waiting hides it. Confirm the exhaustive list with the platform's owners rather than inferring it from a few observed responses.
  • The live URL is often not where you'd expect. Generic "links" or "outputs" maps are frequently user-authored, may contain un-rendered templates, or aren't the app endpoint at all. Find the field that carries the real, reachable URL and prefer a healthy one. Make providerConfig choose it (which service/endpoint) so different workspaces can differ.
  • Provision is usually async; return provisioning. Don't synthesize a URL at create time; let status surface it once the platform reports the env up. Claiming ready early sends the tester at a URL that isn't live yet.
  • Make teardown idempotent. The TTL sweeper calls it, and the platform tombstones the local record even if your call returns 404. Treat "already gone" as success so a double teardown or a race never throws.
  • TTL ownership can surprise you. If the platform applies its own auto-expiry, the value it honours may not be the one you sent (some clamp or override it). Cat Factory owns teardown of what it created; treat the platform's expiry as a backstop and don't rely on a TTL you can't verify round-trips.
  • Watch the platform's input constraints. Many "create" APIs accept exactly one of a set of fields (a PR number or a branch or a commit), and reject more than one, or silently rewrite one into another. Send the single most specific ref you have, and persist the returned id, not the one you sent, in fields.
  • Resolve secrets per call; never construct-time, never logged. The token arrives via resolveSecret on each call (it's decrypted in memory only then). Don't cache it on the instance, don't put it in error messages, and bound your error bodies so a hostile response can't dump into logs.
  • Authentication is a question to confirm. Confirm the scheme (bearer vs. a custom header vs. a signed request) and whether the token is scoped. A token with blanket access is a finding worth raising with the platform's owners.
  • Set timeouts and treat the adapter as untrusted I/O. Use AbortSignal.timeout, cap response sizes, and if any URL is operator-supplied, guard against SSRF (validate the host, don't follow redirects blindly).

This is the deepest extension seam Cat Factory exposes.

Next: Set Up Your Deployment Repository for where this code lives, or the declarative alternative most deployments use in Run Jobs on Your Own Runners and Provision Ephemeral Environments.

Edit this page on GitHub
Last Updated: 8/9/26, 12:31 PM
Prev
Add a Custom Gate or Judge
Next
Extend the App with Frontend Modules