@lepsto/sdk-app
v92.1.0
Published
The Lepsto SDK for App is a TypeScript SDK that provides runtime bindings and type-safe generated code for interacting with Lepsto Platform APIs and services. This package generates type definitions and client bindings from API catalogs, enabling develope
Readme
@lepsto/sdk-app
The Lepsto SDK for App is a TypeScript SDK that provides runtime bindings and type-safe generated code for interacting with Lepsto Platform APIs and services. This package generates type definitions and client bindings from API catalogs, enabling developers to build applications that consume Lepsto platform services with full type safety and autocompletion.
Note: The src/gen directory contains generated SDK code and should not be edited by hand. Generated files are produced by running npm run generate with an API catalog.
The committed src/gen is a convenience snapshot — it lets the repo type-check, test, and build offline. It is not the source of truth: canonical generation happens at publish time, when Cloud Build regenerates src/gen from the live catalog before publishing. The catalog is mid-migration, so the committed snapshot's namespaces (currently organization and playground) will grow as platform extensions migrate to @lepsto-platform/tools — a namespace only appears once its tools carry a REST binding (tools without one are excluded until migrated). Regenerate the snapshot with npx tsx scripts/generate.ts <catalog.json> src/gen && npm run sync-exports against the live catalog (https://api.lepsto.dev/catalog/tools).
Package name: @lepsto/sdk-app
npm i @lepsto/sdk-app@lepsto/sdk-app is the name to install. One build is published on public npmjs under two
names — @lepsto/sdk-app and @lessly/sdk-app — at the same version, with an identical
exports map. Both are supported.
package.json carries name: @lessly/sdk-app, and the dev-console SDK-rebuild Cloud Build job
derives PKG from it (require('./package.json').name) for its npm view probe before running
npm run publish:npmjs -- --tag <channel>. @lepsto/sdk-app is the derived tarball.
| Script | Publishes | Contract |
| --- | --- | --- |
| npm run publish:npmjs -- --tag <channel> | @lessly/sdk-app | token with write on @lessly |
| npm run publish:npmjs:lepsto -- --tag <channel> | @lepsto/sdk-app | same args as publish:npmjs; token with write on @lepsto |
publish:npmjs:lepsto (scripts/publish-alias.ts) builds once, npm packs, rewrites name and description
(the Lepsto text; package.json carries its own description for @lessly/sdk-app),
checks every exports target is in the tarball, and publishes with --registry and
--@lepsto:registry pinned. Run it after publish:npmjs against the same checkout so both names
carry the same version.
Bootstrap (.github/workflows/publish-lepsto.yml, manual): mirrors already-published
@lessly/sdk-app tarballs instead of rebuilding — npm pack @lessly/sdk-app@<v>, rewrite name and description, drop
publishConfig.registry, publish as @lepsto/sdk-app@<v> (tsx scripts/publish-alias.ts --mirror
84.0.0:latest,85.0.0:next). Versions that already exist are skipped. It uses NPM_BOOTSTRAP_TOKEN
and is ready for npm Trusted Publishing (id-token: write, npm >= 11.5.1, token only in the publish
step's env).
Usage
Create a client with createLesslyApp, then call operations through the Proxy namespace tree:
import { createLesslyApp } from '@lepsto/sdk-app';
const sdk = createLesslyApp({
baseUrl: 'https://api.lepsto.dev', // or a page-relative '/api' in the browser
productId: 'prod_123',
// getCsrfToken defaults to reading the `lepsto_csrf` cookie, then `lessly_csrf`;
// override for SSR/tests.
});
const connectors = await sdk.organization.connectors.list({ productId: 'prod_123' });
await sdk.organization.product.create({ name: 'Acme' });The available namespaces track the live catalog and grow as platform extensions migrate; check
package.json exports (or src/gen/manifest.gen.ts) for what a given version exposes.
Operation identity on every method
Every generated REST method is a callable that also carries its own catalog identity, typed as
((input: X) => Promise<Y>) & Operation:
sdk.mail.domain.create.operationKey; // 'mail_domain_create' — the catalog tool name
sdk.mail.domain.create.level; // 'admin' — one of 'read' | 'write' | 'admin'level comes verbatim from the platform catalog — the SDK transports it and never infers it from
readOnly or the HTTP method. Read both off the method rather than rebuilding a tool name from the
accessor path: a hyphenated resource (sdk.tracking['event-names'].list) joins several id
segments, so the path is a lossy view of the name. The properties are own properties of the
function, so they survive destructuring (const { list } = sdk.organization.connectors).
src/gen/manifest.gen.ts additionally exports operations, the whole tool-name -> level table,
for callers that need a level without holding a client.
Composer steps: compositions
A catalog tool may declare a composition — the contract a workflow builder needs to offer that
operation as a step. src/gen/manifest.gen.ts exports the whole table, re-exported from the
package root:
import { compositions } from '@lepsto/sdk-app';
if ('mail_domain_create' in compositions) {
const step = compositions['mail_domain_create'];
step.label; // 'Add a domain' — how a builder names the step
step.completion_event; // 'mail.domain.added' — the event that settles an async step
step.options; // Record<string, string> — builder-level knobs
step.pii; // input fields carrying personal data
step.secrets; // input fields carrying secrets
}Presence is the whole signal. A key is in the map exactly when the catalog gave that tool a
composition, and the value may legitimately be {} ("composable, nothing further to say"). Ask
name in compositions; a truthiness test on label would drop a perfectly composable step.
Every member is optional and the SDK reads none of them — the object is carried verbatim from
the catalog into the generated map, so a key the platform adds reaches your App even before this
SDK's ToolComposition type names it. Unlike operations, the map is not restricted to REST
tools: composability is a property of the operation, not of its transport, so a ws-bound step is
listed too.
A tool gaining, losing or re-declaring a composition ships as a minor — the map only ever gains or loses a key, and no exported symbol appears or disappears with it.
Streaming (<tool>Connect) factories carry neither field: a socket is neither a read nor a write,
and the catalog declares no level for a ws-only tool.
Errors are surfaced as a typed LesslyApiError (status, code, body). Mutating calls
without the lepsto_csrf cookie (or lessly_csrf) fail before the network with status: 0 and
code: 'csrf_cookie_missing' — no retry, no hidden refresh.
TanStack Query
Each namespace subpath (@lepsto/sdk-app/<namespace>) also ships framework-agnostic
query/mutation option factories — plain objects, not hooks — usable with any
TanStack Query adapter (React, Solid, Vue, Svelte). You pass the sdk instance
explicitly:
// React example — works the same with any @tanstack/*-query adapter.
import { useQuery, useMutation } from '@tanstack/react-query';
import {
organizationConnectorsListQueryOptions,
organizationProductCreateMutationOptions,
} from '@lepsto/sdk-app/organization';
function Connectors() {
const { data } = useQuery(organizationConnectorsListQueryOptions(sdk, { productId: 'prod_123' }));
const create = useMutation(organizationProductCreateMutationOptions(sdk));
// create.mutate({ name: 'Acme' })
}The factories return { queryKey, queryFn } (and { mutationKey, mutationFn }),
so they also work directly with queryClient.ensureQueryData(...) and friends.
This package has no runtime dependencies — bring your own TanStack Query adapter.
Publishing
This repo holds source only — humans push code to GitHub, builds run in dev-console via Cloud Build, and the published package lives on npmjs. Nothing commits or pushes back to this repo automatically; the catalog snapshot and changelog are stored in GCS, not in git.
Cloud Build clones the repo read-only and drives the runner-agnostic pipeline CLI:
npm run ci -- --channel <next|latest> --catalog-url <URL> [--baseline <path>]--channel— the npm dist-tag to publish under (nextfor staging,latestfor production).--catalog-url— the public catalog endpoint, fetched with an anonymous GET (staginghttps://api.lepsto.dev/catalog/tools, productionhttps://api.lepsto.com/catalog/tools).--baseline— path to the previous catalog snapshot (downloaded from GCS) used for the content-hash gate and the semver diff-classifier. Omit it for the first publish.
The very first publish on the (empty) npmjs registry is floored at 0.2.0, not 0.1.0,
so it lands strictly above the legacy GAR 0.1.x line that existing consumers pinned (e.g.
^0.1.1). Once a version
exists on a channel's dist-tag, normal semver bumping (and the recovery patch-bump) takes over.
The floor is keyed off the registry, not the flag: if the channel's dist-tag resolves to
nothing (empty npmjs), the run takes the first-publish path even when Cloud Build passes a stored
(GAR-era) baseline — the baseline is ignored and the freshly-fetched catalog ships as the 0.2.0
initial release. So Cloud Build may keep passing the stored baseline through the migration without
erroring.
Because npm versions are immutable package-wide (not per-dist-tag), a missing channel tag does
not by itself mean an empty registry. The pipeline therefore also checks the package-wide max
version: if a tag is absent but the package already carries versions under another tag (e.g. next
published 0.2.0 and a first production run finds latest absent), it force-publishes a fresh
patch bumped past that max so npm publish --tag <channel> seeds the tag without a 409 on the
already-published version. The genuine 0.2.0 first-publish floor applies only when the package
has no versions anywhere.
The CLI fetches the catalog, regenerates src/gen, and compares against the baseline. When
the generated output changed it bumps the version in package.json (base version resolved
from npm view @lessly/sdk-app dist-tags against the configured registry, per channel —
see "Registry: public npmjs" below), appends CHANGELOG.md, and writes:
out/result.json—{ publish_needed, level, version }(always written).out/new-snapshot.json— the new snapshot (written only when a publish is needed; see "Snapshot shape" below).
CHANGELOG.md at the repo root is hand-written and is now in the files array, so it ships in
the tarball. The pipeline appends its machine-rendered ## <version> (<level>) block to that
same file in the build workdir, under the "Pipeline-generated entry" heading kept last in it —
so a release tarball carries the hand-written history plus the one generated block for that
release. Nothing is pushed back to git; the accumulated changelog still lives in GCS. Note that
the content-hash gate covers docs/ and src/runtime + src/gen only, so a commit that only
edits CHANGELOG.md does not trigger a publish — edit it alongside the change it describes.
The CLI performs no git, gcloud, or npm-publish operations — it is pure Node. Cloud Build
reads out/result.json, then runs npm publish and uploads the new snapshot + changelog to
GCS itself.
Registry: public npmjs
The package now publishes to public npmjs, not GAR: package.json sets
publishConfig.registry = https://registry.npmjs.org/ and publishConfig.access = public.
Cloud Build publishes each channel under its own dist-tag — next for staging, latest for
production.
Scope split (#493): @lessly is public-only (npmjs); internal packages live in GAR under
@lessly-platform. The repo .npmrc encodes exactly that — @lessly -> npmjs,
@lessly-platform -> GAR (with always-auth). @lessly/sdk-app is never published to GAR
again. scripts/npmrc.test.ts guards both mappings.
Scope-override gotcha: a scoped .npmrc registry entry outranks both
publishConfig.registry and a bare --registry flag for npm's own resolution — that is how an
early run leaked a lookup to GAR. So every scripted npm call pins the scope explicitly, and the
rebuild_sdk Cloud Build step MUST publish via the canonical script (or the identical command):
npm run publish:npmjs
# == npm publish --registry https://registry.npmjs.org/ --@lessly:registry=https://registry.npmjs.org/Base-version resolution (the npm view lookups inside the pipeline CLI — both the per-channel
version and the package-wide max-version probe) follows the same rule, and in two steps:
it reads the registry from package.json's publishConfig.registry first (falling back to the
.npmrc @lessly:registry value if absent), and passes that registry as an explicit CLI
scope override (--@lessly:registry=<registry>) on every npm view. The --registry flag alone
is not enough: because the package is scoped @lessly, the .npmrc @lessly:registry=<GAR> entry
would otherwise outrank it and silently resolve the lookup against GAR — which is exactly how an
early run read GAR's 0.1.x line instead of the empty npmjs registry and skipped the 0.2.0
floor. This requires no change to the CLI's --channel/--catalog-url/--baseline contract.
Runtime changes: runtime-bump.json
The catalog diff only classifies generated code. The hand-written runtime (src/runtime, plus
src/react when present) is gated separately: the pipeline content-hashes those trees into
runtimeHash and stores it in the snapshot, so a runtime-only change publishes on its own instead
of waiting for an unrelated catalog change.
A runtime change ships at least a PATCH. To publish it at a higher level, commit the marker at the repo root:
{ "level": "minor" }runtime-bump.json—{ "level": "none" | "patch" | "minor" | "major" }; a missing file reads asnone. Any other value fails the run.- The marker applies only while
runtimeHashdiffers from the snapshot's. The pipeline never commits, so it is not reset after use — it simply goes inert once that publish is snapshotted and the hashes match again. Set it in the same commit as the runtime change and leave it at that value; set it back to"none"whenever you like. - The published level is the highest of: the catalog diff (only when the generated output
changed), the runtime level (
max(marker, patch)when the runtime changed), andpatchwhen the shipped docs changed. Amajorcatalog diff therefore outranks aminormarker, and vice versa. - A legacy snapshot with no
runtimeHashpublishes one seeding PATCH (when a runtime is present) to record the hash, after which detection is fully hash-based — the same two-mode shape asdocsHash.
Snapshot shape
out/new-snapshot.json is now a wrapper, { catalog, docsHash, runtimeHash }, rather than a bare
catalog —
docsHash is the content hash of the shipped guide (the docs/*.md + docs/recipes files,
matching the files globs; internal docs/superpowers/ is excluded), added so a docs-only change
(no catalog diff) still triggers a PATCH publish; runtimeHash covers src/runtime + src/react
(see "Runtime changes" above). The file remains an opaque blob to Cloud
Build: store it in GCS exactly as written and pass it back verbatim as --baseline on the next
run. Older, bare-catalog baselines from before this change are still accepted: because such a
baseline predates in-package docs, the first run against it publishes a one-time migration
PATCH (when shipped docs are present) that ships the guide and seeds the wrapper snapshot —
after which docs-change detection is fully hash-based.
