Docs/services-onboarding

Service Onboarding — Backend Dev Quickstart

Audience: backend devs adding new services to Clawvard's unified service layer.

Goal: get your first service live in < 30 minutes.

Not for you if: you're adding an LLM / STT / TTS / image-gen passthrough — those go through Model Service (https://token.clawvard.school, sk-xxx keys), not this layer.

Want depth? Read docs/services-architecture.md after this.


TL;DR — the 4-step loop

  1. Pick a runtime: proxy (stateless 3rd-party API) or job (long async work).
  2. Append a ServiceDefinition to SERVICES in src/lib/services/registry.ts.
  3. (Optional, for typed SDK access) Add I/O interfaces + a ServiceTypeMap entry in packages/sdk/src/service-types.ts.
  4. Run pnpm sdk:gen — the typed cv.<group>.<method>(...) method appears in packages/sdk/src/generated.ts automatically.

Then pnpm tsc --noEmit and smoke-test with curl. That's it. No route edits, no dispatcher edits, no SDK method hand-writing.

Skipping step 3? Your service still works — both via raw HTTP (POST /api/services/invoke/<group>/<method>) and via the untyped escape hatch (cv.workflow.run<MyOutput>("<group>.<method>", input)). You only need step 3 to get strongly-typed cv.<group>.<method>(...).


Before you start

Two demo services already live in the registry as working templates you can copy from:

Service Runtime Purpose
util.ip-check proxy Calls https://api.ipify.org, returns the egress IP. Free.
util.delay-echo job Sleeps N seconds, echoes input. Free.

Open src/lib/services/registry.ts and look at them — they're 30 lines total and exercise the full pipeline (charge → execute → progress → result → refund-on-failure).

Verify your local environment by hitting them first:

# Free, no setup needed
curl -X POST http://localhost:3000/api/services/invoke/util/ip-check \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" -d '{}'
# => { "ip": "1.2.3.4" }

# Job lifecycle — start, get jobId, poll
curl -X POST http://localhost:3000/api/services/invoke/util/delay-echo \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" \
  -d '{"seconds": 5, "payload": {"hello": "world"}}'
# => { "jobId": "<uuid>", "status": "pending", "pollUrl": "/api/services/jobs/<uuid>" }

curl http://localhost:3000/api/services/jobs/<uuid> \
  -H "Authorization: Bearer sk-xxx"
# => { "status": "running" | "completed", "progress": { "pct": 0.5, "note": "tick 2/4" }, ... }

If those work for you, the platform is wired correctly and the rest of this guide is just "make a copy and edit."


Path A — Adding a PROXY service (≈ 30 min)

Proxy services forward a single request to a third-party API (Shotstack, Mux, Replicate, Runway, ElevenLabs direct, etc.) and pipe the response back. The dispatcher charges costCredits up-front and refunds on upstream failure (4xx/5xx or network error).

A.1 Pick a name

Field Convention Example
id <group>.<kebab-method>, globally unique video.render
group URL segment, also the SDK namespace video
method camelCase, becomes cv.<group>.<method>() render

A.2 Add the registry entry

// src/lib/services/registry.ts → inside SERVICES[]
{
  meta: {
    id: "video.render",
    group: "video",
    method: "render",
    summary: "Render a timeline to MP4 via Shotstack.",
    costCredits: 50,                     // user pays 50 cr (≈ ¥5 / $0.50)
    approxCostHintUsd: 0.05,             // optional: our COGS
    access: { beta: true },              // optional gating flags
  },
  handler: {
    kind: "proxy",
    upstreamPath: "/render",                          // joined to base URL below
    upstreamBaseUrlEnv: "SHOTSTACK_BASE_URL",         // e.g. https://api.shotstack.io/v1
    upstreamAuth: {
      type: "header",                                 // or "bearer"
      headerName: "x-api-key",                        // ignored when type:"bearer"
      envVar: "SHOTSTACK_API_KEY",
    },
    transformRequest: (input) => ({
      timeline: input,
      output: { format: "mp4" },
    }),
    transformResponse: (res) => ({
      renderId: (res as { id: string }).id,
    }),
  },
},

Absolute vs relative upstreamPath: if upstreamPath is a full URL (https://...), it's used verbatim and upstreamBaseUrlEnv is ignored. Use absolute when the upstream is fixed; use relative when you want different env values for staging / prod.

In packages/sdk/src/service-types.ts:

// 1. Define the interfaces
export interface VideoRenderInput {
  clips: Array<{ asset: { type: string; src: string }; length: number }>;
}
export interface VideoRenderOutput {
  renderId: string;
}

// 2. Add an entry to the type map
export interface ServiceTypeMap {
  // ...existing entries
  "video.render": { input: VideoRenderInput; output: VideoRenderOutput };
}

Then run codegen:

pnpm sdk:gen
# [sdk:gen] wrote 3 services → packages/sdk/src/generated.ts

The new cv.video.render(input) method appears in generated.ts with proper typing. Do not edit generated.ts by hand — re-run sdk:gen instead.

A.4 Set env vars

In Vercel (or your local .env.local):

SHOTSTACK_BASE_URL=https://api.shotstack.io/v1
SHOTSTACK_API_KEY=sk_live_...

A.5 Test

curl -X POST http://localhost:3000/api/services/invoke/video/render \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" \
  -d '{"clips":[{"asset":{"type":"video","src":"https://..."},"length":10}]}'

Done. Ship.


Path B — Adding a JOB service (2-4 hours)

Job services run > 10 seconds (video transcode, batch processing, multi-step workflows). The dispatcher inserts a service_jobs row, charges credits, kicks your execute function in the background, and returns 202 { jobId, pollUrl } immediately. Failures auto-refund.

B.1 Pre-flight

The service_jobs table is already migrated (supabase/migrations/20260425000001_service_jobs.sql). Nothing to do.

B.2 Add the registry entry

// src/lib/services/registry.ts → inside SERVICES[]
{
  meta: {
    id: "video.remove-silence",
    group: "video",
    method: "removeSilence",
    summary: "Auto-detect and cut silent sections out of a video clip.",
    costCredits: 20,
    access: { beta: true, requiresCoursePurchase: "new-media-editing-101" },
  },
  validateInput: (input) => {
    const o = input as { inputUrl?: unknown };
    if (!o?.inputUrl || typeof o.inputUrl !== "string") return "inputUrl is required";
    return null;
  },
  handler: {
    kind: "job",
    timeoutSec: 300,                          // soft upper bound
    execute: async (input, ctx) => {
      const { inputUrl } = input as { inputUrl: string };

      await ctx.updateProgress?.(0.1, "downloading");
      const localPath = await downloadToScratch(inputUrl);

      await ctx.updateProgress?.(0.4, "analyzing");
      const windows = detectSilenceWindows(localPath);

      await ctx.updateProgress?.(0.85, "rendering");
      const outputUrl = await runFfmpegCut(localPath, windows);

      // Throw to fail. The dispatcher refunds + records the message.
      // Don't catch + return — the auto-refund needs to see the throw.
      return {
        outputUrl,
        cutSeconds: sumWindows(windows),
        segmentsRemoved: windows.length,
      };
    },
  },
},

B.3 Progress reporting

Inside execute, call ctx.updateProgress?.(pct, note). Both args are optional (you can omit progress entirely if your job is opaque). The SDK surfaces this via .onProgress(cb).

B.4 Failure handling

Throw, don't catch. The dispatcher relies on the exception to:

  1. Set status='failed' + persist error_message to service_jobs.
  2. Auto-refund costCredits via grantCredits with type <id>.refund (shows up in user's transaction history as "Refund for failed ...").

If you try/catch and silently return, the user gets charged for a job that crashed. Don't do that.

B.5 Long-running compute

Vercel Fluid Compute caps at 300 seconds. If your real work takes longer:

Option When
Vercel Queues (beta) Any async work > 5 min
Modal / RunPod GPU-bound work (video transcode, SD inference)
Shotstack / Mux / Descript API Don't own the pipeline — use a proxy instead

Pattern: your execute enqueues the real worker, persists the external job ID in the result, and returns. Then a separate worker (your queue consumer) calls back into Supabase to update the service_jobs row when done.

B.6 SDK method + types

Same pattern as proxy services — add the I/O interfaces + a ServiceTypeMap entry in packages/sdk/src/service-types.ts, then pnpm sdk:gen. The codegen knows the runtime is job from the registry and emits Job<Output> (vs Promise<Output> for proxies) automatically.


Idempotency

Clients can pass an Idempotency-Key request header to make retries safe. Same (caller, group, method, key) always maps to the same internal invocationId, which means:

  • Job services: a retry returns the existing jobId instead of creating a second job + double-charging.
  • Proxy services: the unique constraint in credit_transactions swallows the duplicate charge. The upstream call still runs again (idempotency at the upstream's discretion).
# First call: creates a new job, charges 20 cr
curl -X POST .../invoke/video/remove-silence \
  -H "Authorization: Bearer sk-xxx" \
  -H "Idempotency-Key: client-uuid-abc" \
  -d '{"inputUrl": "..."}'
# => { "jobId": "abc...", ... }

# Network blip — client retries with the SAME key
curl -X POST .../invoke/video/remove-silence \
  -H "Authorization: Bearer sk-xxx" \
  -H "Idempotency-Key: client-uuid-abc" \
  -d '{"inputUrl": "..."}'
# => { "jobId": "abc...", ... }   ← same jobId, no second charge
// SDK: use the lower-level client form (the typed namespace methods
// don't take per-call options yet — that's a future enhancement)
const job = cv.client.invokeJob<VideoRemoveSilenceOutput>(
  "video", "removeSilence",
  { inputUrl: "..." },
  { idempotencyKey: crypto.randomUUID() },
);
const result = await job.wait();

A 409 idempotency_key_collision is returned only when the same key is reused by a different user — that's a sign of leaked client state, not a normal retry.


Cancellation

Job services can be cancelled while still pending or running. The platform marks the row cancelled and refunds the credits charged on creation.

curl -X DELETE .../jobs/<jobId> \
  -H "Authorization: Bearer sk-xxx"
# => { "status": "cancelled", "refunded": true }
// SDK
const job = cv.video.removeSilence({ inputUrl: "..." });
// later — user changes their mind
const { status, refunded } = await job.cancel();

Already-terminal jobs (completed / failed) return their existing state with refunded: false. Workers that finish AFTER the cancel land are no-ops — the markJobCompleted / markJobFailed writes have a WHERE status IN ('pending','running') guard so they can't overwrite the cancellation.


Pricing

Set costCredits on the meta — that's it. Conversion to ¥/$ is automatic (10 credits = ¥1, 69 credits = $1).

costCredits ≈ ¥ ≈ $
1 ¥0.10 $0.0145
20 ¥2.00 $0.29
50 ¥5.00 $0.72
200 ¥20.00 $2.90
0 Free Free

Pricing rules of thumb:

  • Always cover COGS × 2 — the upstream API costs us, support / refunds eat the margin.
  • Round to a clean credit number — users see 20 cr, not 17 cr.
  • Free (costCredits: 0) is fine for smoke tests / loss leaders; the dispatcher just skips the charge/refund dance.

To change a price later, edit the number. Past invocations keep their historical price (already-charged rows in credit_transactions).


Per-service rate limit

Add a rateLimit block to the meta. The dispatcher enforces per-(user, service) before charging — a throttled caller gets 429 rate_limit_exceeded with a Reset in <N>s hint. Combine windows as needed:

meta: {
  // ...
  rateLimit: { perMinute: 10, perHour: 100, perDay: 500 },
},

All three are optional. Omit the field entirely for no per-service limit (the route layer's global throttle still applies).


Webhooks (job services only)

Pass X-Webhook-Url: https://you.com/cb (or webhookUrl in the SDK) when invoking a job. When the job reaches a terminal state (completed / failed / cancelled), the platform POSTs the result to that URL with HMAC signing:

POST https://you.com/cb
Content-Type: application/json
X-Clawvard-Signature: sha256=<hex>
X-Clawvard-Job-Id: <jobId>
X-Clawvard-Service-Id: video.remove-silence

{"jobId":"…","serviceId":"…","userEmail":"…","status":"completed","payload":{…},"deliveredAt":"…"}

Verify the signature server-side: `sha256(SERVICE_WEBHOOK_SECRET + "."

  • raw_body) === . Three delivery attempts with 0.5s/1s backoff; fewer than 3× since last success → webhook_status='failed'` in the DB. A receiver that returns 2xx counts as delivered.

Set SERVICE_WEBHOOK_SECRET in your Vercel env to enable. Without it the dispatcher logs and skips webhook delivery (jobs still run + are pollable as usual).


Course gating

Wire a service to a paid course by adding access.requiresCoursePurchase:

meta: {
  // ...
  access: { requiresCoursePurchase: "new-media-editing-101" },
},

Before charging, the dispatcher calls isEnrolled(userEmail, courseId) against course_enrollments. Not enrolled → 402 course_not_purchased with the course id in the hint field, so the UI can upsell.


Local testing

Start the dev server

pnpm dev

Mint a test API key

Hit /token-relay in the browser, click "Create API Key", copy the sk-xxx value. Or use a Supabase session cookie if you're already logged in.

Cookbook

# 1. List the catalog (public, no auth)
curl http://localhost:3000/api/services/catalog | jq

# 2. Invoke a proxy (synchronous JSON)
curl -X POST http://localhost:3000/api/services/invoke/<group>/<method> \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" \
  -d '{...}'

# 3. Invoke a job (returns 202 + jobId)
curl -X POST http://localhost:3000/api/services/invoke/<group>/<method> \
  -H "Authorization: Bearer sk-xxx" \
  -H "Content-Type: application/json" \
  -d '{...}'
# => { "jobId": "...", "pollUrl": "/api/services/jobs/..." }

# 4. Poll
curl http://localhost:3000/api/services/jobs/<id> -H "Authorization: Bearer sk-xxx"

# 5. Your usage history for the service
curl "http://localhost:3000/api/services/usage/<group>/<method>?history=1" \
  -H "Authorization: Bearer sk-xxx"

SDK loopback

import { Clawvard } from "@clawvard/sdk";
const cv = new Clawvard({ apiKey: "sk-xxx", baseUrl: "http://localhost:3000" });

// Proxy
const { ip } = await cv.util.ipCheck();

// Job
const result = await cv.util.delayEcho({ seconds: 5, payload: { hi: 1 } })
  .onProgress((pct, note) => console.log(pct, note))
  .wait();

Common errors & how to read them

HTTP error Meaning Fix
400 invalid_input Your validateInput rejected the body Check the hint for the reason
401 authentication_required No session cookie + no Bearer sk-xxx Mint a key at /token-relay
402 insufficient_credits Balance < costCredits Hint says how many they need
402 course_not_purchased requiresCoursePurchase not satisfied Hint = the course id; upsell it
404 service_not_found id not in registry Check spelling, restart dev server
500 upstream_not_configured Proxy missing env var Set upstreamBaseUrlEnv value
500 upstream_auth_missing Proxy missing secret env Set upstreamAuth.envVar value
500 charge_failed spendCredits RPC errored Check Supabase + RLS
502 upstream_unreachable Network error to third party Check upstream status / DNS

Job-specific (visible when polling /api/services/jobs/{id}):

status Meaning
pending In the queue, not started
running Worker picked it up, executing
completed Success, result is populated
failed Threw — see error.message. Credits were refunded.
cancelled Reserved for future cancel API

Shipping checklist

  • meta.id is unique and kebab-case
  • costCredits chosen (covers COGS × 2 if proxy)
  • Env vars set in Vercel for both Preview + Production (proxy only)
  • (Optional, for typed SDK) I/O types + ServiceTypeMap entry in packages/sdk/src/service-types.ts
  • pnpm sdk:gen ran cleanly (regenerates packages/sdk/src/generated.ts)
  • pnpm tsc --noEmit clean
  • curl test: success path
  • curl test: insufficient-credits path (set balance to 0, expect 402)
  • (Job) curl test: failure path (force a throw, verify refund in credit_transactions)
  • (If gated) requiresCoursePurchase set + course actually exists in course_enrollments
  • Bumped packages/sdk version (only if you changed the public surface)

What this layer does NOT handle

Concern Where it lives
LLM / STT / TTS / image-gen Model Service (token.clawvard.school, sk-xxx)
Per-token pay-as-you-go Model Service (cron quota sync)
Streaming chat completions Model Service (native OpenAI SSE)
Per-key rate limiting Model Service's existing throttle

If your service feels like "send a request to an OpenAI-compatible endpoint and stream tokens back", you don't want this layer — you want to point the OpenAI SDK at Model Service.


Where to read more

  • docs/services-architecture.md — full reference (architecture diagram, types, credits internals, FAQ)
  • src/lib/services/registry.ts — the source of truth for what's live
  • src/lib/services/dispatcher.ts — what runs your execute
  • src/lib/services/usage.ts — how usage stats are derived
  • packages/sdk/src/index.ts — SDK shape

Stuck? Open a thread in #unified-services — paste the id, group, method, the curl command, and the response body.