Use Surfaces from the SDK

Call a published Surface as one typed fluent client — product ops, selective exposure, optional Identity — without a generated client package.

A Surface is how you publish a product API as a fluent SDK experience — without shipping a custom client package per product.

Under the hood, a Surface still rests on the capabilities and workflows you already built. What Surface adds is the ability to select which ops belong in the product, name them the way developers think (users.account, customers.cards.list), and let consumers call that whole set through one Surface client from cliodot.

TypeScript
import { Surface, CliodotApiError } from "cliodot";
import type { CustomerManagement } from "./payment.surface.types";

const api = new Surface<CustomerManagement>({
  baseUrl: "https://flash.example.com",
  slug: "payment",
  apiKey: process.env.SURFACE_API_KEY,
});

const balance = await api.ledger.balance({
  from: "2026-01-01",
  to: "2026-01-31",
});

await api.charge.create({
  email: "a@b.com",
  amount: "1000",
  type: "card",
});

The consuming developer should not need to learn raw routes. They install cliodot, take your types file, and call fluent ops.


What Surface is#

Capabilities and workflows are building blocks. Alone they stay disconnected: different ids, actions, and call sites.

A Surface is the product boundary over those blocks:

  1. You turn on the Surface SDK for that product.
  2. You choose which ops are exposed to the SDK (not every internal route has to be public to the client).
  3. You map each exposed op to a fluent name — namespaces plus a method (users + accountapi.users.account()).
  4. Consumers use new Surface(...) against the published catalog and call that fluent tree.

So Surface is not “another HTTP wrapper.” It is the ability to present your platform work as a coherent product client: one slug, one typed surface, many ops that belong together.

Surface is not:

  • A generated npm client you maintain per Surface
  • OpenAPI docs alone labeled as “the SDK”
  • A replacement for FlosyncClient when you manage the Cliodot platform itself
  • A runtime validator for body/query shapes (generated types are for IntelliSense; runtime is always new Surface(...))
Text
Capabilities + workflows (building blocks)
        ↓
Surface: choose ops, name them as a product API
        ↓
Published surface catalog + optional *.surface.types.ts
        ↓
new Surface() → api.users.account() / api.charge.create(...)

What the Surface ability gives you#

Ability Meaning
Product grouping Bundle the ops that belong to one product (payments, HRMS, ledger) behind one client
Fluent naming Present business operations as api.resource.action(...), including nested resources like api.customers.cards.list(id)
Selective exposure Expose only the ops you want on the SDK; keep the rest off the catalog
Independent SDK channel SDK access is not the same switch as ordinary public access — you can expose an op to Surface without treating every public route as SDK, and you can keep an SDK op available even when ordinary public access for that route is off
Typed DX Optional *.surface.types.ts for autocomplete; the runtime client stays Surface from cliodot
Identity-aware access Optional Identity Provider so Surface consumers authenticate with Identity trust instead of only a Surface key

When an owner finishes shaping a Surface, an external developer should be able to paste the install snippet, add the types file, and call fluent ops without learning the underlying routes.


How ops become fluent#

Every exposed op needs a fluent identity. Mentally it is three pieces:

Piece Role In developer code
path Resource namespaces (can nest) api.users…, api.customers.cards…
op Method on the last namespace .account(), .create(), .list()
arg names Ordered path parameters only .list(customerId, …) fills the path id

Fluent id = path joined with dots + op — for example customers.cards.listapi.customers.cards.list(id, query?).

Argument order stays:

Text
(...pathArgs, body?, query?)

Common shapes:

  • path only → retrieve(id)
  • body only → create(body)
  • path + body → update(id, body)
  • path + query → list(id, query?)

The portal can suggest path/op/arg names from the underlying route; owners edit the fluent preview until it reads like a product API. Two exposed ops cannot share the same fluent id.


What you need#

Requirement Notes
Surface SDK enabled Master switch for the Surface catalog and SDK invokes
Ops exposed to the SDK Each op has a fluent path / op (and arg_names when the route has path params)
Surface base URL Host for this surface (custom domain or surface host)
Auth Surface API key (gw_…) and/or JWT — or Identity iak_ / secret when an Identity Provider is linked

Types file (optional but recommended): download from the portal (e.g. payment.surface.types.ts) and pass it as the generic:

TypeScript
new Surface<CustomerManagement>({ ... })

Without the generic, calls still work; you just lose autocomplete and argument typing.


Install#

Bash
npm install cliodot
# or
yarn add cliodot
TypeScript
import { Surface, CliodotApiError } from "cliodot";

(@cliodot/flosync is the same package when linked locally from this repo.)


Configuration#

TypeScript
new Surface<T>({
  baseUrl: string;       // required — Surface origin, no trailing slash needed
  slug?: string;         // Surface slug when the host serves more than one surface
  apiKey?: string;       // Surface API key (gw_…)
  jwt?: string;          // Bearer token when the surface uses JWT
  apiKeyHeader?: string; // default "X-API-Key"
  version?: string;      // SDK version sent with attestation (defaults to package version)
  fetchSurface?: boolean;// reserved; catalog loads lazily on first call
});

baseUrl + slug#

Setup Example
Custom / flash domain baseUrl: "https://flash.example.com", slug: "payment"
Local domain routing baseUrl: "http://flash.localhost:8901", slug: "payment"

Use the surface API key from generate-key (returned once as gw_…). That key authenticates the client and signs SDK attestation.


Fluent calls#

Exposed ops appear as nested properties ending in a method:

Fluent id Call
ledger.balance api.ledger.balance(query?)
charge.create api.charge.create(body?)
customers.cards.list api.customers.cards.list(customerId, query?)

Argument order#

Text
(...pathArgs, body?, query?)
  • Path args — one string per URL param ({id}, :id, …), in arg_names order. Required at runtime.
  • Body — object when the op accepts a body. Optional in generated types.
  • Query — object for query params. Optional.

Examples:

TypeScript
await api.users.account();
await api.users.create({ email: "a@b.com" });
await api.users.retrieve("usr_123");
await api.users.update("usr_123", { name: "Ada" });
await api.customers.cards.list("cust_123", { limit: "10" });

Input fields on generated body/query interfaces are all optional (field?: …). The surface / capability still enforces what is actually required.


Typed surface file#

Download or copy types from the surface:

GET /surfaces/:surfaceId/sdk/types{ typescript, filename, export_name }

Example shape:

TypeScript
export interface ChargeCreateBody {
  email?: string;
  amount?: string;
  type?: string;
}

export interface CustomerManagement {
  charge: {
    create(body?: ChargeCreateBody): Promise<unknown>;
  };
  ledger: {
    balance(query?: LedgerBalanceQuery): Promise<unknown>;
  };
}

Schemas are resolved in this order for each op:

  1. Surface mapper input_schema (body / query)
  2. Mapper mapping rules
  3. Capability (or workflow trigger) request schema

Use the file as a type-only import; do not instantiate it.

TypeScript
import type { CustomerManagement } from "./payment.surface.types";

Regenerate the file when you expose new ops or capability schemas change.


How the client works#

  1. Lazy catalog — the first method call (or api.loadSurface()) loads which ops this surface exposes.
  2. Op lookup — fluent path + method name must match an exposed op; otherwise SDK_OP_NOT_EXPOSED.
  3. Invoke — the SDK calls that surface op with auth and attestation.
  4. Response — returns data from the surface envelope when present, otherwise the raw JSON body.
TypeScript
const catalog = await api.loadSurface();
const catalogAgain = await api.loadSurface(true);

Auth and attestation#

Every Surface call (catalog + invoke) sends:

Header Purpose
X-API-Key / Authorization Surface auth (when configured)
X-Cliodot-Client: sdk Marks the call as the SDK channel
X-Cliodot-SDK-Version Client version
X-Cliodot-SDK-Timestamp Unix ms
X-Cliodot-SDK-Nonce Random nonce
X-Cliodot-SDK-Signature HMAC-SHA256 of the canonical request

Signing secret is the API key (or JWT if that is what you configured). The SDK channel is independent of other access channels: an op can be SDK-only, open to other clients, both, or neither.


Errors#

Failures throw CliodotApiError:

TypeScript
import { Surface, CliodotApiError } from "cliodot";

try {
  await api.charge.create({ email: "a@b.com" });
} catch (error) {
  if (error instanceof CliodotApiError) {
    console.error(error.message, error.status, error.code, error.data);
  }
}

Common codes:

Code Meaning
SURFACE_LOAD_FAILED Could not load the surface catalog
SDK_OP_NOT_EXPOSED Fluent path is not in the catalog
SURFACE_REQUEST_FAILED Surface returned an error / ok: false

Surface vs typed capabilities vs FlosyncClient#

Surface Typed capability FlosyncClient
Audience App consuming a grouped product surface Authors wiring capabilities inside workflows Platform API (manage capabilities, run remotely, etc.)
Entry new Surface() defineTypedConnector + workflow steps new FlosyncClient()
Role One SDK client over many capabilities / workflows Compile-time contracts for a single capability Manage and operate the Cliodot platform
Types *.surface.types.ts from the surface defineTypedConnector schemas Client method typings

Use Surface when you want consumers to talk to a productized group of capabilities and workflows. Use typed capabilities when you author those building blocks inside Flowsync.


Minimal end-to-end#

  1. In the portal: create a Surface, enable the SDK, expose the capability/workflow ops that belong together (charge.create, ledger.balance, …), generate an API key.
  2. Download types → save as payment.surface.types.ts.
  3. In your app:
TypeScript
import { Surface, CliodotApiError } from "cliodot";
import type { CustomerManagement } from "./payment.surface.types";

const api = new Surface<CustomerManagement>({
  baseUrl: process.env.SURFACE_BASE_URL!,
  slug: "payment",
  apiKey: process.env.SURFACE_API_KEY!,
});

async function main() {
  try {
    const result = await api.ledger.balance({
      from: "2026-01-01",
      to: "2026-01-31",
      perPage: "10",
      page: "1",
    });
    console.log(result);
  } catch (error) {
    console.error((error as CliodotApiError).message);
  }
}

main();