Github|...

Add a backend

Scaffold the outbox table, describe your routes in OpenAPI, and call them with a typed db.run().

  1. Describe your routes in OpenAPI

    Sp00ky derives the callable routes and their payload types from an OpenAPI spec. Each path becomes a route you can pass to db.run(), and the request body schema becomes its argument type.

    api/openapi.yml
    openapi: 3.1.0
    info:
      version: 1.0.0
      title: example
    paths:
      /spookify:
        post:
          requestBody:
            content:
              application/json:
                schema:
                  type: object
                  properties:
                    id:
                      type: string
                      example: thread:kv9b3b...
                  required:
                    - id
          responses:
            '200':
              description: ok
            '404':
              description: Thread not found
            '500':
              description: Internal server error
  2. Register the backend

    spky api add writes the sp00ky.yml entry and generates the outbox table schema for you.

    spky api add \
      --name api \
      --spec ../api/openapi.yml \
      --base-url http://host.docker.internal:3660 \
      --table job \
      --schema-path ./src/outbox/api.surql

    Run it without flags for an interactive prompt. The resulting config looks like this:

    sp00ky.yml
    apps:
      api:
        type: backend
        baseUrl: http://host.docker.internal:3660
        spec: ../api/openapi.yml
        method:
          type: outbox
          table: job
          schema: ./src/outbox/api.surql
    FieldMeaning
    typebackend for an outbox-invoked HTTP service.
    specPath to the OpenAPI file. Required. It’s where routes and types come from.
    baseUrlWhere the job runner sends requests. Use host.docker.internal to reach a service on your host from the containerised stack.
    method.typeoutbox. The only supported trigger today.
    method.tableName of the outbox table in SurrealDB.
    method.schemaPath where the generated table definition is written.
    Note Validate the file at any point with spky lint.
  3. Review the generated outbox table

    spky api add writes a DEFINE TABLE with every field the job runner needs. You don’t have to import it anywhere: the CLI appends method.schema to your schema automatically whenever it builds, so migrations and codegen pick it up.

    schema/src/outbox/api.surql
    DEFINE TABLE job SCHEMAFULL
    PERMISSIONS
      FOR select, create, update, delete WHERE true;
    
    DEFINE FIELD path    ON TABLE job TYPE string
    PERMISSIONS FOR create, select WHERE true FOR update WHERE false;
    
    DEFINE FIELD payload ON TABLE job TYPE any
    PERMISSIONS FOR create, select WHERE true FOR update WHERE false;
    
    DEFINE FIELD status  ON TABLE job TYPE string DEFAULT ALWAYS "pending"
    ASSERT $value IN ["pending", "processing", "success", "failed"]
    PERMISSIONS FOR create, select WHERE true FOR update WHERE false;
    
    -- the backend's response body, written by the job runner
    DEFINE FIELD result  ON TABLE job TYPE any
    PERMISSIONS FOR select WHERE true FOR create, update WHERE false;
    
    -- … plus retries, max_retries, retry_strategy, errors, delay,
    --    assigned_to, assignee, created_at, updated_at
    FieldWritten byPurpose
    path, payloadclientWhich route to call, and with what.
    statusrunnerpending → processing → success | failed.
    resultrunnerYour backend’s response body.
    errorsrunnerOne { code, reason } entry per failed attempt.
    retries, max_retries, retry_strategymixedAttempt budget; linear or exponential.
    delayclientOne-shot delay in ms before the job becomes due.
    assigneeplatformWhich SSP instance claimed the row.
    Don't loosen the write permissions

    Client code may create rows and read them back, but status, result, errors and assignee are FOR update WHERE false. The job runner is the single writer of job status. Making these client-writable lets a browser lie about whether work succeeded.

  4. Regenerate types
    spky generate

    Backend names and route paths are now part of your generated schema, so db.run() autocompletes them and typechecks the payload.

  5. Call it
    // 'api' is the key under apps:, '/spookify' comes from the OpenAPI spec
    await db.run('api', '/spookify', { id: thread.id });

    db.run() resolves once the job row is written, not when your backend replies. Watch the row to follow the actual work. See Jobs.

Implementing the route

Your service is an ordinary HTTP server. The job runner POSTs the payload to baseUrl + path and stores whatever JSON you return in the row’s result field.

api/src/index.ts
import { Hono } from 'hono';

const app = new Hono();

app.post('/spookify', async (c) => {
  const { id } = await c.req.json();
  const summary = await doTheWork(id);
  return c.json({ summary });   // lands in the job row's `result`
});

export default { port: 3660, fetch: app.fetch };

A non-2xx response marks the attempt failed and schedules a retry according to the row’s max_retries and retry_strategy. Return quickly, or raise the timeout.

Running it locally

Add a dev block and spky dev starts your backend alongside SurrealDB and the SSP. See Dev servers & sidecars.

sp00ky.yml
apps:
  api:
    type: backend
    # …
    dev:
      type: npm
      script: dev
      workdir: ../api