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.
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:
- You turn on the Surface SDK for that product.
- You choose which ops are exposed to the SDK (not every internal route has to be public to the client).
- You map each exposed op to a fluent name — namespaces plus a method (
users+account→api.users.account()). - 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
FlosyncClientwhen you manage the Cliodot platform itself - A runtime validator for body/query shapes (generated types are for IntelliSense; runtime is always
new Surface(...))
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.list → api.customers.cards.list(id, query?).
Argument order stays:
(...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:
new Surface<CustomerManagement>({ ... })Without the generic, calls still work; you just lose autocomplete and argument typing.
Install#
npm install cliodot
# or
yarn add cliodotimport { Surface, CliodotApiError } from "cliodot";(@cliodot/flosync is the same package when linked locally from this repo.)
Configuration#
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#
(...pathArgs, body?, query?)- Path args — one string per URL param (
{id},:id, …), inarg_namesorder. Required at runtime. - Body — object when the op accepts a body. Optional in generated types.
- Query — object for query params. Optional.
Examples:
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:
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:
- Surface mapper
input_schema(body / query) - Mapper mapping rules
- Capability (or workflow trigger) request schema
Use the file as a type-only import; do not instantiate it.
import type { CustomerManagement } from "./payment.surface.types";Regenerate the file when you expose new ops or capability schemas change.
How the client works#
- Lazy catalog — the first method call (or
api.loadSurface()) loads which ops this surface exposes. - Op lookup — fluent path + method name must match an exposed op; otherwise
SDK_OP_NOT_EXPOSED. - Invoke — the SDK calls that surface op with auth and attestation.
- Response — returns
datafrom the surface envelope when present, otherwise the raw JSON body.
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:
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#
- 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. - Download types → save as
payment.surface.types.ts. - In your app:
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();