dvmkitdocs

SDK Reference

Build DVMs with @dvmkit/sdk. Declare capabilities, inputs, pricing, and handlers. The SDK handles transport, payment, streaming, and persistence.

@dvmkit/sdk is the recommended way to build a DVM. You declare what each capability does, and the SDK handles the HTTP protocol, payment, streaming, persistence, and discovery surfaces.

import { configureDVM } from "@dvmkit/sdk";

export default configureDVM({
  name: "echo",
  capability: "echo",
  tags: ["text"],

  onJob(ctx) {
    ctx.complete(ctx.input);
  },
});

Install an exact version in a Node.js 22 or later project. @dvmkit/sdk is pre-1.0 beta software: pin the version you install and review it before upgrading.

npm install --save-exact @dvmkit/sdk@0.6.0-rc.1

Add pg when the DVM will use the built-in Postgres stores in production:

npm install pg

Running it

Use serve() for the usual one-DVM process:

import { serve } from "@dvmkit/sdk/server";
import dvm from "./handler.js";

await serve(dvm, {
  database: process.env.DATABASE_URL,
});

The server listens on PORT or 8080. Production needs Postgres unless you supply an explicit jobStore. devMode: true is the local and test escape hatch.

Test a running DVM with the caller CLI:

dvm request --endpoint http://localhost:8080 -i "hello" --human

serve(dvm, options) is a convenience around createDVMHost(options), host.mount(dvm), and host.serve(). Use createDVMHost when you need host-level routes or several DVM descriptors in one process.

A real DVM: web content extraction

import { configureDVM } from "@dvmkit/sdk";

export default configureDVM({
  name: "scrape",
  description: "Extract clean markdown from any URL",
  capability: "extract",
  tags: ["web", "extraction"],
  price: "$0.01",

  async onJob(ctx) {
    const url = typeof ctx.input === "string" ? ctx.input.trim() : "";
    if (!url.startsWith("http")) {
      ctx.fail("Input must be a valid URL");
      return;
    }

    ctx.text("Fetching content...");

    const response = await ctx.fetch(`https://r.jina.ai/${url}`);
    if (!response.ok) {
      ctx.fail(`Fetch failed: ${response.status.toString()}`);
      return;
    }

    const markdown = await response.text();
    ctx.artifact({ data: markdown, mime_type: "text/markdown" });
    ctx.complete("Content extracted");
  },
});

The wire request names the capability. The SDK assigns the job ID and streams messages:

Client: POST /v1/job { capability: "extract", data: "https://example.com" }
Server: 202 { job_id: "job-abc-1" }
Client: GET /v1/job/job-abc-1/messages (SSE)
Server: { seq: 1, type: "text",     content: { text: "Fetching content..." } }
Server: { seq: 2, type: "artifact", content: { data: "# Example...", mime_type: "text/markdown" } }
Server: { seq: 3, type: "complete", content: { summary: "Content extracted" } }

configureDVM

configureDVM(config) validates your config and returns a frozen DVMDescriptor. The flat single-capability shape becomes a one-entry capabilities map internally.

DVM-level fields

These fields stay at the top level in both descriptor shapes:

FieldTypeRequiredDescription
namestringyesDisplay name
descriptionstringDVM description
tagstringOne discovery tag
tagsstring[]Discovery tags
currencystringLowercase ISO 4217 pricing currency. Defaults to "usd"
idleTimeoutnumberSeconds before automatic cancellation. Defaults to 3600
paymentMethods("cashu" | "x402" | "tempo")[]Override the set derived from configured rails
credit{ min?, max?, ttl?, allowOneShotStablecoin? }Offer prepaid credit; one-payment stablecoin funding is opt-in
authDVMAuthSchemeRequire signed caller requests
routes(app: Hono, context: DVMRouteContext) => void | Promise<void>Register DVM-scoped HTTP routes; context includes signed-route and compatibility helpers
onBoot(env) => void | Promise<void>Run before the listener opens
onShutdown() => void | Promise<void>Run during host shutdown

Per-capability fields

In the flat shape these fields sit at the top level. In the multi-capability shape they sit inside each capabilities[name] entry:

FieldTypeRequiredDescription
capabilitystringCapability name in the flat shape
descriptionstringCapability description
inputZod schemaParse and type ctx.input
stateobject literalPer-job state defaults
pricestringStatic USD upfront price such as "$0.05"
onQuoteQuoteConfigDynamic pricing handler
onJob(ctx) => void | Promise<void>yesHandle a new job
onResponse(ctx, content) => void | Promise<void>Handle a prompt response
onPayment(ctx, content) => void | Promise<void>Handle a client payment
onApproval(ctx, content) => void | Promise<void>Handle a client approval
onCancel(ctx, content) => void | Promise<void>Handle cancellation
onMessage(ctx, message) => void | Promise<void>Handle an unrecognized message

Tags

Tags are free-form strings advertised in /v1/info. Clients use them to match providers to job types:

tag: "text";
tags: ["web", "extraction"];

Pricing

Static prices are USD literals. The SDK resolves them at request time through its FX source:

price: "$0.05";

The accepted static range is "$0.0001" through four decimal places. A raw msat price is invalid. Use onQuote when the price depends on the input or needs more precision. Free capabilities omit price.

Pricing in a currency other than USD

Set a DVM-level currency when quotes and credit should use another supported currency:

currency: "eur";

The bundled FX source supports usd, eur, gbp, and jpy. A dynamic quote must return the declared currency. A static price is always a USD literal, so a non-USD DVM must price through onQuote.

The quote carries both the display amount and the exact amount_micro integer. Use the integer when a caller or credit ledger must reproduce the charge exactly.

Capabilities

The capability is the unit a builder offers and a caller selects. Every DVM has at least one. /v1/info lists each capability with its description, input schema, and pricing.

Single-capability DVMs

Use the flat shape for the common case:

import { configureDVM, z } from "@dvmkit/sdk";

export default configureDVM({
  name: "uppercase",
  capability: "uppercase",
  input: z.object({ text: z.string() }),
  price: "$0.01",

  async onJob(ctx) {
    ctx.complete(ctx.input.text.toUpperCase());
  },
});

Multi-capability DVMs

Put each handler, input schema, price, and state shape in capabilities. DVM-level fields such as name, tags, auth, routes, onBoot, and onShutdown remain shared:

configureDVM({
  name: "cast",
  tags: ["podcast", "rss"],

  capabilities: {
    "add-episode": {
      description: "Add an episode to a private RSS feed",
      input: addEpisodeSchema,
      price: "$0.02",
      async onJob(ctx) {
        /* ... */
      },
    },
    "delete-episode": {
      description: "Delete an episode from a feed",
      input: deleteEpisodeSchema,
      async onJob(ctx) {
        /* ... */
      },
    },
  },
});

Capability names

Names use lowercase letters, digits, and hyphens. They must start with a letter or digit and match /^[a-z0-9][a-z0-9-]*$/. add-episode is valid. Add Episode and cast/add-episode are not.

Wire dispatch

The caller sends capability in the request body. The remaining payload is in data:

{
  "capability": "add-episode",
  "data": {
    "feed_slug": "morning-news",
    "audio_url": "https://example.com/episode.mp3"
  }
}

The SDK selects the matching capability before payment and input validation. A missing or unknown capability returns a structured 400 response.

The job context

onJob receives an SDKJobContext. It exposes the job input, caller identity available to the handler, message methods, flow control, state, storage, environment, and logging.

Identity

PropertyTypeDescription
jobIdstringServer-assigned job ID
tagsstring[]Tags from the descriptor
inputInputRaw input or parsed schema output
paramsRecord<string, string>Query-style job parameters
requesterIdstringOpaque requester identifier
paidMsatsnumberTotal payment received so far
auth{ pubkey, envelope } | undefinedVerified signed-request identity

ctx.auth is present only when the descriptor declares signed-request auth. Its pubkey is trusted after the SDK verifies the request.

Messaging

ctx.text("Working on it...");
ctx.artifact({ data: markdown, mime_type: "text/markdown" });
ctx.sendMessage("custom-type", { value: 1 });

Flow control

const response = await ctx.prompt("language", "Which language?", {
  options: ["Spanish", "French", "German"],
});

await ctx.requestPayment(
  { amount: 0.1, currency: "usd" },
  "Processing fee"
);

ctx.working(30, "Generating output...");
ctx.progress(45, "transcribing", "Halfway through transcription");

ctx.complete("Done");
ctx.fail("Something went wrong");

prompt() and requestPayment() suspend the handler until the caller responds. A numeric payment amount is raw msats. A { amount, currency } envelope is converted through the SDK FX source.

working() closes the SSE stream and tells the caller to return later. progress() keeps the stream open and emits a non-yield heartbeat. Its percentage is clamped to 0 through 100.

Cancellation

Long-running work should observe ctx.signal:

const response = await ctx.fetch(providerUrl);

ctx.fetch is instrumented and already carries ctx.signal, so the request is traced and aborted when the caller cancels or the job becomes stale. Pass ctx.signal separately to provider SDKs or other work that accepts an AbortSignal.

Platform services

Inside onJob(ctx), the SDK supplies:

ctx.state.progress = 50;
await ctx.store.set("rate:btcusd", 65_000, { ttl: 300 });
const cached = await ctx.store.get<number>("rate:btcusd");
ctx.env.API_KEY;
ctx.log.info("Processing", { jobId: ctx.jobId });
const result = await ctx.step("fetch", async () => {
  const response = await ctx.fetch("https://api.example.com/data");
  return response.json();
});
ctx.signal;

Report builder costs

Call ctx.cost() where your handler incurs a vendor or compute cost. The amount is in major currency units, like other handler-facing money values:

const transcript = await ctx.step("transcribe", async () => {
  const result = await whisper(audio);
  ctx.cost({ amount: result.seconds * 0.0001, currency: "usd" });
  return result;
});

Repeated declarations add. The SDK sums the major-unit amounts and rounds the job total once to a millionth of the currency, so fine per-token charges are not individually rounded away. Use one lowercase ISO currency throughout a job; mixing currencies makes the total unreportable rather than converting at an invented rate.

No declaration means “unknown”, while ctx.cost({ amount: 0, currency: "usd" }) explicitly reports that the job was free to serve. Declarations inside ctx.step() are stored with the step and replayed exactly once when the body is skipped. Paid completions send the cost with revenue; free completions, failures, and cancellations report it separately, so a cost never invents a sale.

Multi-turn conversations

Linear async style

Use an async handler when the work reads naturally from top to bottom:

configureDVM({
  name: "translator",
  capability: "translate",
  async onJob(ctx) {
    const language = await ctx.prompt("language", "Translate to?");
    const result = await translate(ctx.input, language.text);
    ctx.complete(result);
  },
});

Event-driven style

Use onResponse when explicit message routing fits the DVM better:

configureDVM({
  name: "translator",
  capability: "translate",
  state: { original: "" },

  onJob(ctx) {
    ctx.state.original = ctx.input;
    ctx.prompt("language", "Translate to?");
  },

  async onResponse(ctx, content) {
    ctx.complete(await translate(ctx.state.original, content.text));
  },
});

Both styles produce the same wire protocol.

Per-job state

ctx.state is inferred from the state config field. Each job receives its own copy:

configureDVM({
  name: "counter",
  capability: "count",
  state: { step: 0, language: "" },

  async onJob(ctx) {
    ctx.state.step += 1;
    ctx.state.language = "English";
    ctx.complete(ctx.state.step.toString());
  },
});

With a database, state is persisted at action boundaries and terminal states. A restart can resume from the last persisted snapshot.

Cross-job storage

ctx.store is an async key-value store scoped to the DVM. Use it for caches, preferences, or data that outlives one job:

import { configureDVM } from "@dvmkit/sdk";

type UserPrefs = { topics: string[] };

export default configureDVM({
  name: "preferences",
  capability: "preferences",
  async onJob(ctx) {
    const key = `user:${ctx.requesterId}`;
    const prefs = await ctx.store.get<UserPrefs>(key);
    if (!prefs) {
      await ctx.store.set(key, { topics: ["technology"] });
    }

    await ctx.store.set("rate:btcusd", 65_000, { ttl: 300 });
    ctx.complete("Preferences loaded");
  },
});

Do not use ctx.store for credit balances. The SDK credit ledger uses database locks and idempotency rules that a last-write-wins key-value record cannot provide.

Input validation with Zod

The SDK exports its bundled Zod instance. Pass a schema as input to receive parsed, typed input:

import { configureDVM, z } from "@dvmkit/sdk";

const inputSchema = z.object({
  prompt: z.string(),
  width: z.number().default(1024),
  height: z.number().default(1024),
});

export default configureDVM({
  name: "image-generator",
  capability: "generate",
  input: inputSchema,

  async onJob(ctx) {
    ctx.text(`Generating ${ctx.input.width}x${ctx.input.height} image`);
    ctx.complete(ctx.input.prompt);
  },
});

The schema appears in /v1/info as JSON Schema. Invalid input is rejected before onJob runs.

Dynamic pricing with onQuote

Use onQuote when cost depends on the request. Return an upfront fiat envelope and describe what it covers:

configureDVM({
  name: "video-transcriber",
  capability: "transcribe",
  onQuote: {
    schema: z.object({ duration_seconds: z.number() }),

    async handler(ctx) {
      const usd = ctx.data.duration_seconds * 0.0005;
      return {
        upfront: { amount: Number(usd.toFixed(4)), currency: "usd" },
        description: `${ctx.data.duration_seconds}s video at $0.0005 per second`,
        shape: "fixed",
      };
    },
  },

  async onJob(ctx) {
    /* ... */
  },
});

The two pricing shapes are:

  • fixed: the upfront covers the complete job.
  • rate: the DVM charges a measured amount after work. Add rate: { per_unit, unit, max_units? } so typed callers can derive a ceiling.

The SDK converts the upfront at the 402 handshake. /v1/quote gives the caller the input-specific amount before submission.

Mid-job payment

Request more payment after work has started:

configureDVM({
  name: "assessor",
  capability: "assess",
  price: "$0.01",

  async onJob(ctx) {
    const assessment = await ctx.step("assess", () => assessInput(ctx.input));

    await ctx.requestPayment(
      { amount: assessment.cost, currency: "usd" },
      assessment.description
    );

    ctx.complete(await processInput(ctx.input));
  },
});

If the upfront payment still covers the requested amount, the SDK credits the request without another payment round trip. Otherwise the handler yields a payment-request and resumes after payment.

Prepaid credit

Prepaid credit lets a caller fund a balance once and draw from it across jobs. Every payment still goes through the SDK credit ledger. A normal per-job payment is the implicit one-job case.

Credit configuration and lifecycle

Opt in with a credit block and signed caller auth:

import { configureDVM } from "@dvmkit/sdk";
import { secp256k1Auth } from "@dvmkit/sdk/server";

export default configureDVM({
  name: "assessor",
  capability: "assess",
  price: "$0.01",
  auth: secp256k1Auth(),
  credit: {
    min: "$0.10",
    max: "$5.00",
    ttl: 30 * 24 * 60 * 60,
    allowOneShotStablecoin: false,
  },

  async onJob(ctx) {
    /* ... */
  },
});

min and max use the same USD literal grammar as price. max limits the residual balance a caller may hold. It does not limit a job whose payment funds and draws in one request. Credit requires descriptor-level auth and a durable Postgres-backed host. Isolate-runtime DVMs do not offer credit.

The SDK owns the credit ledger, POST /v1/credit, funding menu, balance reads, draw idempotency, and receipts. Do not store balances in ctx.store.

credit_id, draw_id, and fund are reserved top-level fields in the signed request data. The SDK removes them before capability-schema parsing. Do not declare them in your schema or use those names for your own fields.

Draws are holds. A completed job settles its draw. A failed or cancelled job releases it, including failures from ctx.fail() and stale-job cancellation. ctx.fail(error, { refund: true }) is only a caller-fault annotation for the terminal record and test inspection; it does not trigger a rail refund or control draw release. Draw release happens for every failed or cancelled job.

Caller view

Quotes can inspect the caller without spending the balance:

onQuote: {
  schema: z.object({ pages: z.number() }),
  async handler(ctx) {
    const covered = ctx.credit && ctx.credit.availableMicro >= ctx.data.pages * 10_000;
    return {
      upfront: { amount: ctx.data.pages * 0.01, currency: "usd" },
      description: covered
        ? "This request is covered by your existing credit"
        : "This request costs $0.01 per page",
    };
  },
}

ctx.callerPubkey is the verified caller key when auth is enabled. ctx.credit is a read-only snapshot with creditId, currency, balanceMicro, availableMicro, expiryMs, and expired.

Funding credit over Lightning

Set DVMKIT_NWC_RECEIVE_URI to a receive-only Nostr Wallet Connect connection. The SDK then adds lightning to the credit funding menu. The connection may make invoices and look them up. It must not pay invoices.

The caller flow is interactive:

  1. The caller signs POST /v1/credit with op: "fund" and a Lightning funding method.
  2. The SDK returns a 402 containing a bolt11 invoice bound to the credit, funding ID, and amount.
  3. The caller pays the invoice out of band.
  4. A later poll, balance read, or job submit checks the invoice and credits the ledger in one transaction.

There is no background watcher. A wallet outage delays recognition of a paid invoice; it does not discard the invoice-to-credit binding.

The receive channel has a smallest routable amount. Set DVMKIT_LIGHTNING_FUNDING_MIN_SATS to state it, or keep the default of 2000 sats until you measure the channel. The menu publishes a rate-adjusted lightning_min_micro floor. A funding below the raw floor is refused as below_rail_minimum before an invoice exists. If the FX source cannot render the floor, Lightning is removed from the menu.

Expiry and reclaim

Expiry stops new draws. The balance remains visible and can be reclaimed until the DVM's expiry sweep releases it. The sweep releases eligible undrawn balances only when no pending draw remains, records the release, and sets the balance to zero. Tempo-funded and Tempo-channel-backed credits are excluded from that sweep. A caller that funds an expired credit revives it; the ledger can reverse a prior expiry release when restoring the credit.

Rail-specific delivery

Reclaim is a builder obligation on the rail that holds the caller's value:

Funding shapeWho completes the reclaimBuilder action
Cashu or LightningBuilder's refund workerRun dvmctl melt-pending; it parks ecash locked to the caller's refund key
Reusable x402 or Tempo channelThe channelKeep the channel settlement and close observer healthy
One-shot x402 or Tempo paymentBuilder operatorReview dvmctl credit drains-owed, then record dvmctl credit drain-settle

A Lightning-funded credit is reclaimed as P2PK-locked ecash. The DVM's receive-only NWC credential cannot fund that refund. The separate refund worker uses existing ecash or a spending connection supplied through DVMKIT_REFUND_NWC_URL to buy ecash from an accepted mint. Its --refund-nwc option names the same role; keep secret values out of command arguments. The worker holds the builder payout while an ecash refund is outstanding or drain state cannot be read.

One-shot stablecoin credit is disabled by default. Set allowOneShotStablecoin: true only when you have a manual refund process:

dvmctl credit drains-owed <handle>
dvmctl credit drain-settle <handle> <credit-id> <drain-id> --tx <hash>

An unfulfilled reclaim is a signed IOU. The caller holds the signed request and the DVM's countersigned drain receipt. The receipt makes the obligation inspectable; it is not a promise that bypasses the builder's payout process.

The reclaim amount is fixed when the caller requests it. For ecash funding, the SDK tracks funding lots and returns the corresponding asset value less the fixed delivery reserve. Amounts below dust are refused as drain_below_dust because there is no spendable output to send.

Tempo session channels

Reusable Tempo session funding uses an on-chain channel. A caller can request a close and wait through the grace period, so the channel must be observed and settled for the value already consumed.

Platform-hosted DVMs receive close events from the platform observer. The SDK re-reads the event from the configured Tempo RPC before acting. Self-hosted DVMs must watch their escrow for CloseRequested and POST the event locator to /_internal/tempo-close-event inside the grace period. If you do not operate an observer, advertise tempo/charge rather than tempo/session.

An existing channel remains usable while new session channels are withheld during an observer or fee-balance problem. The SDK keeps that distinction so a caller's open balance is not stranded.

Retried paid submits are idempotent

A paid submit can reach the DVM even when the caller loses the response. Retry with the same X-Cashu-Request-Id and identical request. The SDK returns the original job response, without creating another job or booking payment twice.

The retry fingerprint includes capability, parameters, and input. A DVM with signed auth also requires the same caller pubkey. Possession of an already-spent payment token alone does not grant access to the job.

Signed receipts

When a receipt key and compatible job store are configured, each terminal job response carries a DVM-signed receipt:

{
  "v": 1,
  "job_id": "job-a7571b-1",
  "dvm_id": "dvm-wordcount",
  "capability": "count",
  "outcome": "completed",
  "reason": null,
  "seq": 4213,
  "issued_at": 1789300000,
  "requester_pubkey": null,
  "paid": { "msats": 19000, "rail": "cashu" },
  "result_hash": "<sha256 hex>",
  "receipt_pubkey": "<64-hex>",
  "signature": "<128-hex>"
}

The same receipt appears on the submit response, job read, and later reads. It is signed once and persisted. /v1/info advertises receipts: true when issuance is available.

The signature is BIP-340 Schnorr over canonical JSON. A verifier checks the receipt signature, the DVM ID and receipt key against /v1/info, and the builder attestation against the builder identity. Free jobs can receive receipts too. Paid receipts may carry a credit block with the credit ID, draw ID, amount, balance after, and ledger sequence.

Receipt issuance does not fail a job. If signing or persistence fails, the SDK logs the failure and can retry on a later read. A custom job store must support receipt sequence allocation and write-once receipt storage to advertise receipts.

Durable steps

ctx.step(id, fn) caches an async result. On replay after a restart or timeout, the SDK returns the cached result instead of calling fn again:

const analysis = await ctx.step("analyze", () =>
  classifyInput(ctx.input, ctx.env.ANTHROPIC_API_KEY)
);

if (analysis.type === "code") {
  await ctx.prompt("language", "Which language?");
}

Step results are persisted at yield points and terminal states when Postgres is configured. Step external API calls and nondeterministic model calls. Plain formatting and arithmetic do not need steps.

Testing

createTestContext() gives you an in-memory context for handler tests:

import { describe, expect, it } from "vitest";
import { createTestContext } from "@dvmkit/sdk/testing";
import dvm from "./handler.js";

describe("echo", () => {
  it("completes a job", async () => {
    const ctx = createTestContext({ input: "hello" });
    await dvm.capabilities.echo.onJob(ctx);

    expect(ctx.completed).toBe(true);
    expect(ctx.summary).toBe("hello");
  });
});

Test context options

createTestContext({
  input: "test input",
  tags: ["text"],
  params: { format: "markdown" },
  env: { API_KEY: "test-key" },
  state: { step: 0 },
  paidMsats: 10000,
  responses: { language: { text: "Spanish" } },
  payment: { cashu_token: "cashuA..." },
});

Test inspection

PropertyDescription
messagesOutbound messages with their type and content
completedWhether complete() was called
failedWhether fail() was called
summaryThe summary passed to complete()
failErrorThe error passed to fail()
refundRequestedWhether fail() requested a refund annotation

Custom HTTP routes

Use custom routes for HTTP endpoints beside the protocol routes. Keep DVM-owned endpoints on the descriptor. Keep cross-DVM or host maintenance endpoints on host.app. The descriptor callback receives (app, context), where context.authAudience is the audience for signed custom routes and context.requireClientCompatibility is the compatibility middleware factory.

DVM-scoped routes

The callback runs on the DVM's Hono sub-router and inherits its mount prefix:

function createCastDVM(runtime: CastRuntime) {
  return configureDVM({
    name: "cast",
    capabilities: {
      /* ... */
    },

    routes(app) {
      app.get("/feeds/:owner/:slug", async (c) => {
        const feed = await runtime.db.renderFeed(c.req.param("owner"), c.req.param("slug"));
        return c.body(feed, 200, { "Content-Type": "application/rss+xml" });
      });
    },
  });
}

Host-scoped routes

Register host.app routes before mounting DVMs:

import { createDVMHost } from "@dvmkit/sdk/server";

const host = createDVMHost();
host.app.get("/health", (c) => c.json({ ok: true }));
host.mount(createCastDVM(runtime));
await host.serve();

Endpoint compatibility requirements

routes(app, context) receives requireClientCompatibility. Apply it only to the endpoint that needs a released caller capability:

routes(app, { requireClientCompatibility }) {
  app.post(
    "/request-resume",
    requireClientCompatibility({
      requiredCapabilities: ["request-resume"],
      minimumClientVersion: "0.1.0",
    }),
    async (c) => c.json({ ok: true })
  );
}

The SDK parses compatibility headers for telemetry and adds DVM-Protocol-Version to responses. Do not reject every request globally for a route-specific feature.

Authentication: signed requests

Use signed requests when a DVM owns caller-specific state such as feeds, accounts, or subscriptions. The SDK uses secp256k1 with BIP-340 Schnorr signatures. The signed statement covers the exact raw input, attested DVM audience, HTTP method, concrete path, and capability.

Open access is the default. A DVM that does not need caller ownership can omit auth and use the opaque requesterId.

The capability schema includes the caller payload and signed envelope:

import { z } from "@dvmkit/sdk";

const addEpisodeSchema = z.object({
  feed_slug: z.string(),
  audio_url: z.string().url(),
  pubkey: z.string(),
  signature: z.string(),
  timestamp: z.number(),
  nonce: z.string(),
});

Canonical pattern: secp256k1Auth on the descriptor

Declare auth once on the descriptor. It covers every capability and advertises secp256k1-schnorr-v2 in /v1/info:

import { secp256k1Auth } from "@dvmkit/sdk/server";

configureDVM({
  name: "cast",
  tags: ["podcast", "rss"],
  auth: secp256k1Auth({
    replayStore: runtime.replays,
    driftSeconds: 300,
  }),
  capabilities: {
    "add-episode": {
      input: addEpisodeSchema,
      async onJob(ctx) {
        const pubkey = ctx.auth!.pubkey;
        const { feed_slug } = ctx.auth!.envelope as { feed_slug: string };
        await checkFeedOwnership(pubkey, feed_slug);
        /* ... */
      },
    },
  },
});

Unsigned, stale, replayed, and wrong-domain envelopes return 401 before payment or handler execution. The nonce is recorded only after upfront payment verifies, so an unpaid 402 discovery response can be followed by a paid retry using the same signed job proof.

Use a cross-machine replay store in a multi-instance deployment. With a Postgres host, the SDK wires PostgresReplayStore when you omit replayStore. The bounded in-memory store is suitable for a single instance. Set DVMKIT_FAIL_FAST=true if an unsafe fallback should stop production boot.

Lower-level primitive: createSignedRequestVerifier

Use the verifier directly when a custom route needs signed callers:

import {
  createSignedRequestVerifier,
  secp256k1Auth,
  SignedRequestError,
} from "@dvmkit/sdk/server";

const verifier = createSignedRequestVerifier(addEpisodeSchema, {
  driftSeconds: 300,
});

configureDVM({
  name: "cast",
  capability: "add-episode",
  input: addEpisodeSchema,
  auth: secp256k1Auth(),
  onJob(ctx) {
    ctx.complete("Route-ready");
  },
  routes(app, { authAudience }) {
    if (!authAudience) throw new Error("signed route requires descriptor auth");

    app.post("/private/feed", async (c) => {
      try {
        const verified = verifier.checkAuth(await c.req.json(), {
          audience: authAudience,
          method: c.req.method,
          path: new URL(c.req.url).pathname,
          capability: null,
        });
        await verifier.recordReplay(verified);
        return c.json({ owner: verified.pubkey });
      } catch (error) {
        if (error instanceof SignedRequestError) {
          return c.json({ error: "auth_error", sub_reason: error.sub_reason }, 401);
        }
        throw error;
      }
    });
  },
});

Call checkAuth() synchronously to validate schema, timestamp, and signature. Call recordReplay() separately to commit the nonce. This keeps validation from consuming a nonce before the operation is accepted.

Options

OptionDefaultDescription
driftSeconds300Allowed clock drift
domainnoneFixed audience, method, path, and capability
envelopeFields["signature"]Additional fields removed from canonical bytes
replayStorebounded in-memory FIFOCross-machine store, or null to disable replay checks
nowcurrent Unix secondsClock override for tests

Signing in tests

The verifier exposes signRequest() with the same canonicalization as checkAuth():

import { schnorr } from "@noble/curves/secp256k1.js";

const { secretKey } = schnorr.keygen();
const signed = verifier.signRequest(secretKey, {
  feed_slug: "morning-news",
  audio_url: "https://example.com/episode.mp3",
}, undefined, {
  audience: authAudience,
  method: "POST",
  path: "/private/feed",
  capability: null,
});

Quote paths

/v1/quote and the 402 discovery hop run checkAuth() without consuming the nonce. Quote and submit use separate proofs because the signed path differs. Do not call recordReplay() from onQuote().

Failure taxonomy

The verifier reports SignedRequestError with code: "auth_error" and one of these reasons:

sub_reasonCause
schema_invalidEnvelope or payload fields are missing or malformed
timestamp_driftThe timestamp is outside driftSeconds
signature_invalidBIP-340 signature verification failed
replay_detectedThe caller reused a nonce in the replay window

Capability input errors are separate. A bad capability payload returns 400 invalid_input. A malformed auth envelope reaches the auth gate and returns 401.

Lifecycle hooks

onBoot(env)

onBoot runs once per mounted DVM after stores initialize and before the HTTP listener opens. Throw to refuse boot on missing configuration:

configureDVM({
  name: "narrate",
  capability: "synthesise",
  onBoot(env) {
    if (!env.ELEVENLABS_API_KEY && !env.OPENAI_API_KEY) {
      throw new Error("Missing TTS provider keys");
    }
  },
});

onShutdown()

onShutdown runs in reverse mount order before the host closes. Use it to drain provider work and close resources:

configureDVM({
  name: "scrape",
  capability: "fetch",
  async onShutdown() {
    await pool.shutdown();
  },
});

Job content retention

The SDK defaults to jobRetentionDays: 730. Set a shorter or longer non-negative whole number of days in configureDVM, or 0 to disable automatic age expiry. The clock starts at the job's final activity and stays fixed after completion, failure, or cancellation. Reading status or messages never renews it.

Authenticated job responses expose content_expires_at as epoch milliseconds or null, and content_available separately. A null deadline means no current automatic age expiry; it does not promise permanent storage. An older service that omits these fields has unknown retention. Content expiry removes input, parameters, results, messages, temporary state, and replay-only authentication material. It preserves the terminal outcome, payment links, and full signed receipt.

The container/Postgres adapter supports signed service-admin content holds. A support or dispute hold applies to one job and records an actor, reason, and review date. Multiple holds compose. The effective expiry is null while any hold is active, while the original baseline deadline stays visible to admins. An overdue review never clears a hold. Explicitly clearing the last hold restores the original deadline, which may already have passed. Redaction and hold mutations serialize on the same job. A hold cannot restore content already removed.

Use dvmctl content-holds to list, place, renew, or clear these holds. The service verifies the builder's signing identity; a platform token alone is insufficient. Hold reasons and actor identities are admin-only. Unsupported custom stores return an unavailable-controls response. Isolate stores do not gain these controls merely by upgrading the SDK.

The SDK package supplies recipes/postgres/financial-export-v1.sql for a builder-owned minimized financial export. It is a SQL recipe, not a content archive endpoint. Save and verify it before destruction if you want that evidence. Active-service content retention does not extend trial recovery or the maximum 90-day lifetime of hosted copies after destruction.

Persistence

Pass database to serve() or set DATABASE_URL:

await serve(dvm, { database: process.env.DATABASE_URL });

The SDK creates Postgres-backed job and KV stores. It persists job status, per-job state, durable-step results, and ctx.store data. Tables are created at boot.

Postgres is required for production unless you provide a compatible jobStore. MemoryJobStore is for tests and development. If your own store creates SDK-adjacent tables during boot, use the SDK's initialization lock so two machines cannot race their DDL.

Deployment

Container

Use a production entry point that imports the handler and reads deployment configuration from the environment:

import { serve } from "@dvmkit/sdk/server";
import dvm from "./handler.js";

await serve(dvm, {
  database: process.env.DATABASE_URL,
  mints: process.env.DVMKIT_CASHU_MINTS?.split(",").filter(Boolean),
});

The same process can advertise Cashu, x402, or Tempo according to the rails you configure. Keep payment secrets out of source files and logs.

Builder-facing serve() options

serve(dvm, options) and createDVMHost(options) accept the same host options:

This is the builder-facing subset of the public DVMHostOpts type. The omitted consumedCredentialStore, mintHealthTracker, and wrapRouteClientCompatibility fields are test or platform integration seams. The payment overrides are included here because they are the programmatic path when environment-derived rail wiring is not enough.

OptionTypeDefaultDescription
portnumberPORT or 8080HTTP listen port
databasestringDATABASE_URLPostgres connection string
envRecord<string, string>process environmentValues available as ctx.env
builderBuilderIdentityenvironment-derivedBuilder identity in /v1/info
ownerOwnerDisplayenvironment-derivedOwner display identity in /v1/info
platformReporterPlatformReporterOptsenvironment-derivedRevenue reporting connection
fxFxFetcherenvironment-configuredFX source override
healthHandler(c) => ResponseSDK health responseCustom /health handler
storeKVStorePostgres-backedKV store override
jobStoreJobStoredatabase-backedExplicit job store
poolPoolnoneReuse an existing Postgres pool; the caller owns its lifecycle
mintsstring[]DVMKIT_CASHU_MINTSAccepted Cashu mints
cashuModeCashuModeexplicitCashu receive mode, including p2pk-accumulator
mppMppxServerenvironment-resolvedTempo/MPP payment handle override
x402X402Configenvironment-resolvedx402 payment configuration override
paymentMethodsPaymentMethod[]Derived from railsAdvertised payment methods
lightningReceiveLightningReceiveConfigenvironment-resolvedReceive-only Lightning leg for credit funding
devModebooleanfalseLocal/test behavior and in-memory fallback

Payment-rail environment variables

The SDK derives advertised payment methods from the server configuration:

VariableEffect
DATABASE_URLPostgres connection string. Required in production
PORTListen port. Defaults to 8080
DVMKIT_CASHU_MINTSComma-separated Cashu mint URLs
DVMKIT_CASHU_LOCK_PUBKEYP2PK lock key for accumulator-mode Cashu
DVMKIT_DVM_IDStable DVM identity used by durable payment and receipt records
DVMKIT_NWC_RECEIVE_URIReceive-only wallet connection for Lightning credit funding
DVMKIT_LIGHTNING_FUNDING_MIN_SATSAdvertised Lightning receive floor
DVMKIT_TEMPO_RECIPIENTTempo settlement recipient
DVMKIT_TEMPO_OPERATOR_KEYFee-funded operator key for reusable Tempo sessions
DVMKIT_TEMPO_SECRET_KEYHMAC secret for MPP challenge binding. Required whenever the Tempo rail is configured; generate with openssl rand -hex 32
DVMKIT_TEMPO_RPC_URLTempo RPC endpoint
DVMKIT_TEMPO_METHODSTempo method allowlist
DVMKIT_X402_PAY_TOx402 settlement recipient
DVMKIT_X402_NETWORKx402 chain identifier
DVMKIT_PLATFORM_URLPlatform reporter endpoint and hosted-DVM signal
DVMKIT_PLATFORM_TOKENPlatform reporter credential
DVMKIT_FX_SOURCEFX source override
DVMKIT_RECEIPT_KEYReceipt-signing key
DVMKIT_FAIL_FASTEscalate warning-level boot checks to failures

DVMKIT_* variables configure the server. DVM_* variables configure the caller. Unprefixed legacy names are not read.

The revenue-reporter boot check

When DVMKIT_PLATFORM_URL is set, the SDK requires DVMKIT_PLATFORM_TOKEN, DVMKIT_DVM_ID, DATABASE_URL, and the reporter's required stores. Partial platform wiring fails boot instead of silently accepting payments without a reporting path. devMode is exempt.

The reporter queues payout and pending-balance facts after the SDK's payment work. It does not change payment settlement or handler behavior.

DVMKIT_FAIL_FAST

Set DVMKIT_FAIL_FAST=true in production when a warning-level boot check should stop the process. This is useful for catching an in-memory replay store on a multi-instance signed DVM. Partial platform reporter wiring is fatal regardless of this flag.

createDVMHost for advanced wiring

Use createDVMHost for host routes, several mounted DVMs, shared pool access, or custom shutdown ordering:

import { createDVMHost } from "@dvmkit/sdk/server";

const host = createDVMHost();
host.app.get("/health", (c) => c.json({ ok: true }));
host.mount(createMyDVM({ getPool: () => host.pool }));

await host.serve();

Local development

dvmctl dev handler.ts starts a hot-reloading server with devMode: true:

dvmctl dev handler.ts

Without configured mints, dev mode skips payment verification and uses an in-memory store when no database is set. It sets requesterId to dev-anonymous. Set DVMKIT_CASHU_MINTS when you want to exercise the payment path against a test mint.

The dev server resolves a default export first, then a named dvm export, then the first DVM descriptor it finds. Port priority is --port, .env PORT, process.env.PORT, then 3000.

Testing payments locally

Point a dev server at a local test mint:

DVMKIT_CASHU_MINTS=http://localhost:3338 dvmctl dev handler.ts

Fund a local caller wallet and submit normally:

dvm wallet init
dvm wallet mint-add http://localhost:3338
dvm wallet fund 100 --sats --mint http://localhost:3338 --test-skip-lightning
dvm request --endpoint http://localhost:3000 -i "hello" --human

--test-skip-lightning is for local test mints only. It is not a production funding method.

What the framework handles

You do not need to hand-build these parts:

  • Capability dispatch for /v1/job, /v1/quote, and /v1/info.
  • Input-schema parsing before onJob.
  • Upfront 402 payment gating.
  • Cashu, x402, and Tempo payment verification for configured rails.
  • SSE messages, sequence numbers, yields, and terminal states.
  • Credit funding, draw holds, release-on-failure, and idempotency.
  • Durable jobs, state, steps, and KV storage when Postgres is configured.
  • Signed receipts when a receipt key and compatible job store are available.
  • Platform revenue reporting when DVMKIT_PLATFORM_* is configured.

Further reading

On this page