API · Public API + client
A public API projected from the app's own procedures — REST under /v1 with the standard profile, typed errors, pagination, Idempotency-Key — with an OpenAPI spec + Swagger UI and a generated TypeScript client in both flavors (Promise without Effect, Effect service) that voltro dev keeps written and you publish to npm. Zero-infra boot.
The public API that a partner programs against, built without a second definition of anything: three procedures carry publicApi and become GET /v1/products (paginated), GET /v1/products/{productId} (a typed 404) and POST /v1/products (scope-guarded, Idempotency-Key). The same descriptors produce the OpenAPI document, Swagger UI, and — what this template exists to show — the generated TypeScript client a consumer installs from npm. Boots with zero infra (store: 'memory'). Template id: api-public.
Beside it: api-rest is the same surface hand-written with defineRestRoute; api-backend is the reactive rpc side.
What you get
| Piece | Where |
|---|---|
| The catalog | database/schema.ts — products with timestampMs on the wire |
| The surface | queries/products.list.query.ts, products.get.query.ts, mutations/products.create.mutation.ts — publicApi: { resource: 'products', … }, errorStatus, idempotent, an authored example |
| The wire shape and the typed error | lib/product.ts — browser-safe, imported by every descriptor |
| The decision | app.config.ts — publicApi.profile, publicApi.client, artifacts: ['openapi', 'typescript'] |
| The spec | /openapi.json + /docs, from the same projections (includeAppProcedures) |
| The client | sdk/typescript/ — written by voltro dev, @acme/api-client at . (Promise, no Effect) and ./effect |
| Tests | tests/products.test.ts — the executors against the in-memory store, the projection read off the descriptors |
The client, in both flavors
import { createClient, ProductNotFound } from '@acme/api-client'
const api = createClient({ baseUrl: 'https://api.example.com', token: process.env.API_KEY })
const page = await api.products.list({ limit: 20 }) // items[].createdAt is a Date
for await (const p of api.products.list.pages()) { /* every page, Link walked for you */ }
try { await api.products.get({ productId: 'prod_missing' }) } catch (e) { if (e instanceof ProductNotFound) { /* 404, typed */ } }
await api.products.create({ name: 'Widget', priceCents: 1999 }) // Idempotency-Key minted per call
for await (const live of api.products.list.subscribe()) { /* the current page and every change, over the socket */ }import { Effect } from 'effect'
import { ApiClient } from '@acme/api-client/effect'
const program = Effect.gen(function* () {
const api = yield* ApiClient
return yield* api.products.list()
}).pipe(Effect.catchTag('ProductNotFound', () => Effect.succeed({ items: [], nextCursor: null })), Effect.provide(ApiClient.layer({ baseUrl, token })))The consumer picks: . pulls in no Effect at all; ./effect needs effect and @voltro/api-client-effect, declared as optional peers.
Change the decision, publish
voltro api-client show # the decision, defaults filled in
voltro api-client set --flavor plain # the Promise client only — no Effect in the package
voltro api-client build # write sdk/typescript now
cd sdk/typescript && pnpm install && pnpm build && pnpm publish --access publicset edits publicApi.client in app.config.ts through the TypeScript AST; the voltro api-client page has every flag.
Auth, honestly
A single dev-only API key (publicdemo_devkey, its SHA-256 in app.config.ts) makes the guarded mutation work the moment you boot. It is a sample password: delete it before anything real, and resolve the hash against your own key store — or turn on the framework's first-class API keys (apiKeys: true).