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:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name |
description | string | DVM description | |
tag | string | One discovery tag | |
tags | string[] | Discovery tags | |
currency | string | Lowercase ISO 4217 pricing currency. Defaults to "usd" | |
idleTimeout | number | Seconds 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 | |
auth | DVMAuthScheme | Require 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:
| Field | Type | Required | Description |
|---|---|---|---|
capability | string | Capability name in the flat shape | |
description | string | Capability description | |
input | Zod schema | Parse and type ctx.input | |
state | object literal | Per-job state defaults | |
price | string | Static USD upfront price such as "$0.05" | |
onQuote | QuoteConfig | Dynamic pricing handler | |
onJob | (ctx) => void | Promise<void> | yes | Handle 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
| Property | Type | Description |
|---|---|---|
jobId | string | Server-assigned job ID |
tags | string[] | Tags from the descriptor |
input | Input | Raw input or parsed schema output |
params | Record<string, string> | Query-style job parameters |
requesterId | string | Opaque requester identifier |
paidMsats | number | Total payment received so far |
auth | { pubkey, envelope } | undefined | Verified 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. Addrate: { 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:
- The caller signs
POST /v1/creditwithop: "fund"and a Lightning funding method. - The SDK returns a
402containing a bolt11 invoice bound to the credit, funding ID, and amount. - The caller pays the invoice out of band.
- 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 shape | Who completes the reclaim | Builder action |
|---|---|---|
| Cashu or Lightning | Builder's refund worker | Run dvmctl melt-pending; it parks ecash locked to the caller's refund key |
| Reusable x402 or Tempo channel | The channel | Keep the channel settlement and close observer healthy |
| One-shot x402 or Tempo payment | Builder operator | Review 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
| Property | Description |
|---|---|
messages | Outbound messages with their type and content |
completed | Whether complete() was called |
failed | Whether fail() was called |
summary | The summary passed to complete() |
failError | The error passed to fail() |
refundRequested | Whether 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
| Option | Default | Description |
|---|---|---|
driftSeconds | 300 | Allowed clock drift |
domain | none | Fixed audience, method, path, and capability |
envelopeFields | ["signature"] | Additional fields removed from canonical bytes |
replayStore | bounded in-memory FIFO | Cross-machine store, or null to disable replay checks |
now | current Unix seconds | Clock 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_reason | Cause |
|---|---|
schema_invalid | Envelope or payload fields are missing or malformed |
timestamp_drift | The timestamp is outside driftSeconds |
signature_invalid | BIP-340 signature verification failed |
replay_detected | The 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.
| Option | Type | Default | Description |
|---|---|---|---|
port | number | PORT or 8080 | HTTP listen port |
database | string | DATABASE_URL | Postgres connection string |
env | Record<string, string> | process environment | Values available as ctx.env |
builder | BuilderIdentity | environment-derived | Builder identity in /v1/info |
owner | OwnerDisplay | environment-derived | Owner display identity in /v1/info |
platformReporter | PlatformReporterOpts | environment-derived | Revenue reporting connection |
fx | FxFetcher | environment-configured | FX source override |
healthHandler | (c) => Response | SDK health response | Custom /health handler |
store | KVStore | Postgres-backed | KV store override |
jobStore | JobStore | database-backed | Explicit job store |
pool | Pool | none | Reuse an existing Postgres pool; the caller owns its lifecycle |
mints | string[] | DVMKIT_CASHU_MINTS | Accepted Cashu mints |
cashuMode | CashuMode | explicit | Cashu receive mode, including p2pk-accumulator |
mpp | MppxServer | environment-resolved | Tempo/MPP payment handle override |
x402 | X402Config | environment-resolved | x402 payment configuration override |
paymentMethods | PaymentMethod[] | Derived from rails | Advertised payment methods |
lightningReceive | LightningReceiveConfig | environment-resolved | Receive-only Lightning leg for credit funding |
devMode | boolean | false | Local/test behavior and in-memory fallback |
Payment-rail environment variables
The SDK derives advertised payment methods from the server configuration:
| Variable | Effect |
|---|---|
DATABASE_URL | Postgres connection string. Required in production |
PORT | Listen port. Defaults to 8080 |
DVMKIT_CASHU_MINTS | Comma-separated Cashu mint URLs |
DVMKIT_CASHU_LOCK_PUBKEY | P2PK lock key for accumulator-mode Cashu |
DVMKIT_DVM_ID | Stable DVM identity used by durable payment and receipt records |
DVMKIT_NWC_RECEIVE_URI | Receive-only wallet connection for Lightning credit funding |
DVMKIT_LIGHTNING_FUNDING_MIN_SATS | Advertised Lightning receive floor |
DVMKIT_TEMPO_RECIPIENT | Tempo settlement recipient |
DVMKIT_TEMPO_OPERATOR_KEY | Fee-funded operator key for reusable Tempo sessions |
DVMKIT_TEMPO_SECRET_KEY | HMAC secret for MPP challenge binding. Required whenever the Tempo rail is configured; generate with openssl rand -hex 32 |
DVMKIT_TEMPO_RPC_URL | Tempo RPC endpoint |
DVMKIT_TEMPO_METHODS | Tempo method allowlist |
DVMKIT_X402_PAY_TO | x402 settlement recipient |
DVMKIT_X402_NETWORK | x402 chain identifier |
DVMKIT_PLATFORM_URL | Platform reporter endpoint and hosted-DVM signal |
DVMKIT_PLATFORM_TOKEN | Platform reporter credential |
DVMKIT_FX_SOURCE | FX source override |
DVMKIT_RECEIPT_KEY | Receipt-signing key |
DVMKIT_FAIL_FAST | Escalate 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
- Build a DVM: builder access and the current release path.
- Protocol specification: wire formats, yields, and transport.
- Funding: how payment rails move and return value.
- Credits: caller-facing prepaid balances and reclaim.
- Caller quickstart: run a paid job with the
dvmCLI.