SDK
Typed delivery + management client for Atlas CMS — read, write, publish, and delete content with full type safety.
TypeScript SDK
@latellu/atlas-sdk provides two clients for Atlas:
- Delivery client — read entries, pages, and media from the Public API (
/api/v1/public/*). - Management client — create, update, publish, and delete content via the Management API (
/api/v1/manage/*).
Paired with the generated types from @latellu/atlas-cli, your content types, slugs, and entry data are all type-checked at compile time.
Install
npm install @latellu/atlas-sdk
# Generate typed interfaces for your workspace (recommended):
npx @latellu/atlas-cli generate --api-key=atlas_live_abc123xyz --output=./srcGet your API key from the Atlas dashboard → Settings → API Keys.
Requires Node 18+ (uses global fetch). For older runtimes, pass fetchImpl.
Delivery client
Read entries, pages, and media with full type safety.
Create a client
import { createClient } from '@latellu/atlas-sdk';
import type { AtlasContentTypes } from './atlas.types'; // from @latellu/atlas-cli
export const atlas = createClient<AtlasContentTypes>({
url: 'https://api.atlas.latellu.com',
apiKey: process.env.ATLAS_API_KEY!,
});createClient<TSchema>(config) returns a client whose entries() method is constrained to the content-type slugs in TSchema. Without the type parameter, slugs accept any string and data is unknown.
Config options:
| Option | Type | Required | Description |
|---|---|---|---|
url | string | Yes | Atlas backend base URL. The SDK adds /api/v1/public automatically. |
apiKey | string | Yes | Workspace API key (atlas_live_...). |
fetchImpl | typeof fetch | No | Custom fetch for tests or older runtimes. |
Entries
atlas.entries(type) returns a resource scoped to one content type.
List entries:
const { items, total, page, pageSize } = await atlas
.entries('article')
.list({ locale: 'en', page: 1, limit: 20, sort: 'published_at:desc' });| Option | Type | Description |
|---|---|---|
locale | string | Locale to resolve content into. |
page | number | 1-based page number. |
limit | number | Entries per page. |
sort | string | Sort expression, e.g. "created_at:desc". |
Get one entry:
const post = await atlas.entries('article').get('hello-world', { locale: 'en' });
// post is AtlasEntry<T> or null (on 404)When locale is passed, the matching translation is merged over the base data — translated fields win, untranslated fields fall back.
Entry shape:
| Field | Type | Description |
|---|---|---|
id | string | Entry id. |
slug | string | URL slug. |
status | string | published, draft, or archived. |
published_at | string | null | ISO timestamp. |
data | T | Parsed entry data, typed as your content type. |
Pages
// Lightweight list (no blocks) — good for sitemaps or nav
const { items } = await atlas.pages.list({ locale: 'en' });
// Full page with SEO + blocks resolved for the locale
const home = await atlas.pages.get('home', { locale: 'en' });pages.list(options?)→ListResult<AtlasPageSummary>(id, slug, status — no blocks).pages.get(slug, options?)→AtlasPage | nullwith SEO and blocks resolved for the locale, blocks sorted byposition.
Media
const asset = await atlas.media.get(mediaId);
// asset is MediaAsset or null (on 404)Raw requester
For delivery endpoints not yet wrapped by a resource:
const { data, meta } = await atlas.raw.get<MyShape>('/some/public/path', { locale: 'en' });Path is relative to /api/v1/public.
Management client
Create, update, publish, and delete content from scripts, CI jobs, or AI agents.
Authenticates with a management key (atlas_mgmt_...). See Authentication to mint one.
Wrong key class fails fast
Passing an atlas_live_ (delivery) key throws ManagementConfigError immediately.
Promise surface (default)
import { createManagementClient, AtlasError } from '@latellu/atlas-sdk/management';
const client = createManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});Effect-native surface
For codebases built on Effect:
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect } from 'effect';
const client = makeManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});
// Every method returns Effect<T, AtlasError>
const program = client.entries('article').publish('hello-world');
// Run at the boundary
await Effect.runPromise(program);Delivery client stays Effect-free
Importing @latellu/atlas-sdk or @latellu/atlas-sdk/management never pulls Effect into your bundle. Only .../management/effect depends on Effect.
Composing multiple operations
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';
const client = makeManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});
// Compose multiple operations into a single program
const program = Effect.gen(function* () {
// Create an entry
const entry = yield* client.entries('article').create({
slug: 'hello-world',
data: { title: 'Hello, world' },
});
yield* Console.log(`Created: ${entry.id}`);
// Publish it
yield* client.entries('article').publish('hello-world');
yield* Console.log('Published');
// Update it
yield* client.entries('article').update('hello-world', {
data: { title: 'Updated title' },
});
yield* Console.log('Updated');
return entry;
});
await Effect.runPromise(program);Error handling with Effect
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';
const client = makeManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});
// catchTag for specific error handling
const program = client.entries('article').create({
slug: '',
data: {},
}).pipe(
Effect.catchTag('AtlasError', (err) => {
if (err.status === 400 && err.errors) {
// Validation errors
return Console.error('Validation failed:', err.errors);
}
if (err.status === 429) {
// Rate limit — SDK already retried, this is after retries exhausted
return Console.error('Rate limited — try again later');
}
return Console.error(`Failed: ${err.status} ${err.message}`);
}),
);
await Effect.runPromise(program);Retry policies
The SDK automatically retries 429 responses with exponential backoff (200ms, 400ms, 800ms — max 3 retries), including on media uploads (client.media.upload). For custom retry logic:
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Schedule, Duration } from 'effect';
const client = makeManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});
// Custom retry: retry on 500 errors with linear backoff
const program = client.entries('article').create({
slug: 'hello-world',
data: { title: 'Hello' },
}).pipe(
Effect.retry({
schedule: Schedule.exponential(Duration.millis(100)).pipe(
Schedule.compose(Schedule.recurs(5)),
),
while: (err: AtlasError) => err.status === 500,
}),
);
await Effect.runPromise(program);Parallel operations
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect } from 'effect';
const client = makeManagementClient({
url: 'https://api.atlas.latellu.com',
token: process.env.ATLAS_MGMT_KEY!,
});
// Publish multiple entries in parallel
const slugs = ['post-1', 'post-2', 'post-3'];
const program = Effect.all(
slugs.map((slug) => client.entries('article').publish(slug)),
{ concurrency: 3 }, // Run 3 at a time
);
await Effect.runPromise(program);Entries
Full lifecycle: create → publish → update → unpublish → delete.
const created = await client.entries('article').create(
{ slug: 'hello-world', data: { title: 'Hello, world' } },
{ idempotencyKey: crypto.randomUUID() }
);
await client.entries('article').publish('hello-world');
await client.entries('article').update('hello-world', { data: { title: 'Updated' } });
await client.entries('article').unpublish('hello-world');
await client.entries('article').delete('hello-world');All entry methods:
| Method | Description |
|---|---|
.create(input, opts?) | Create a draft entry |
.update(idOrSlug, input, opts?) | Update an entry |
.publish(idOrSlug, opts?) | Publish (visible to delivery API) |
.unpublish(idOrSlug, opts?) | Unpublish |
.archive(idOrSlug, opts?) | Archive |
.schedule(idOrSlug, publishAt, opts?) | Schedule future publish |
.duplicate(idOrSlug, opts?) | Clone as new draft |
.delete(idOrSlug, opts?) | Delete |
.bulk(operations, opts?) | Batch operations |
Pages
const page = await client.pages.create({
slug: 'about',
seo: { title: 'About us' },
blocks: [{ type: 'hero', data: { heading: 'About us' } }],
});
await client.pages.publish('about');
await client.pages.blocksReorder('about', ['block-1', 'block-2']);All page methods: .create, .update, .delete, .publish, .unpublish, .archive, .schedule, .blocksReorder.
Media
const file = await fetch('https://example.com/cover.jpg').then((r) => r.blob());
const asset = await client.media.upload(file, { alt: 'Cover image' });
await client.media.delete(asset.id);Idempotency
All write methods accept { idempotencyKey?: string } to prevent duplicates on retry. The client also auto-retries 429 responses with exponential backoff.
Type generation
@latellu/atlas-cli generates TypeScript interfaces from your workspace schema.
Generate
Get your API key from the Atlas dashboard → Settings → API Keys.
npx @latellu/atlas-cli generate --api-key=atlas_live_abc123xyz --output=./src/types| Flag | Env | Default | Description |
|---|---|---|---|
--api-key | ATLAS_API_KEY | — (required) | Workspace API key. |
--url | ATLAS_API_URL | https://api.atlas.latellu.com | Atlas backend URL. |
--output | ATLAS_OUTPUT | ./src/atlas.types.ts | Output file or directory. |
What it generates
// Generated by @latellu/atlas-cli — DO NOT EDIT BY HAND.
// Workspace: my-blog
export type Locale = "en" | "id";
/** Article (content type: "article") */
export interface Article {
title: string; // localizable
category?: "News" | "Event";
}
export interface AtlasContentTypes {
"article": Article;
}- One
interfaceper content type (PascalCase from slug). selectfields as string-literal unions.Localeunion from workspace locales.AtlasContentTypesregistry consumed bycreateClient<AtlasContentTypes>().
Keep types in sync
Regenerate after schema changes (add/rename/remove content types or fields):
{
"scripts": {
"atlas:types": "atlas generate --output=./src/types"
}
}Commit atlas.types.ts to your repo for reproducible builds.
Error handling
Delivery client
get()methods returnnullfor 404 — normal value, not an exception.- Everything else (network failures, auth errors, rate limits, validation) throws
AtlasError.
import { createClient, AtlasError } from '@latellu/atlas-sdk';
const atlas = createClient({ url: '...', apiKey: 'atlas_live_xxx' });
// get() returns null for 404 — no exception
const entry = await atlas.entries('article').get('nonexistent');
if (entry === null) {
console.log('Not found — handle gracefully');
}
// list() throws on failure
try {
await atlas.entries('article').list();
} catch (err) {
if (err instanceof AtlasError) {
console.error(err.status); // 401, 403, 429, 500, etc.
console.error(err.code); // "UNAUTHORIZED", "FORBIDDEN", etc.
console.error(err.message); // Human-readable description
}
}Management client (Promise surface)
Same AtlasError as delivery, with additional .errors array for validation failures:
import { createManagementClient, AtlasError } from '@latellu/atlas-sdk/management';
const client = createManagementClient({ url: '...', token: 'atlas_mgmt_xxx' });
try {
await client.entries('article').create({
slug: '', // invalid — empty slug
data: {},
});
} catch (err) {
if (err instanceof AtlasError) {
console.error(err.status); // 400, 401, 403, etc.
console.error(err.code); // "VALIDATION_ERROR", etc.
console.error(err.message); // "slug is required"
console.error(err.errors); // [{ field: "slug", message: "slug is required" }]
}
}Management client (Effect surface)
With Effect, errors are typed as the second type parameter of Effect<A, E>:
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';
const client = makeManagementClient({ url: '...', token: 'atlas_mgmt_xxx' });
// Type-safe error handling with Effect.catchTag
const program = client.entries('article').create({
slug: 'hello-world',
data: { title: 'Hello' },
}).pipe(
Effect.catchTag('AtlasError', (err) =>
Console.error(`Failed: ${err.status} ${err.message}`)
),
);
await Effect.runPromise(program);AtlasError properties
| Property | Type | Description |
|---|---|---|
message | string | Human-readable error description. |
status | number | undefined | HTTP status code. Absent for network errors. |
code | string | undefined | Machine-readable code (e.g. UNAUTHORIZED, FORBIDDEN). |
errors | { field: string; message: string }[] | undefined | Validation errors (management client only). |
ManagementConfigError
Thrown immediately (before any request) when the management client is misconfigured:
import { createManagementClient, ManagementConfigError } from '@latellu/atlas-sdk/management';
try {
// Wrong key prefix — throws immediately
createManagementClient({ url: '...', token: 'atlas_live_xxx' });
} catch (err) {
if (err instanceof ManagementConfigError) {
console.error(err.message); // "management client requires an atlas_mgmt_ token"
}
}Common error scenarios
| Scenario | Status | Code | How to handle |
|---|---|---|---|
| Missing API key | 401 | UNAUTHORIZED | Check X-API-Key header is set. |
| Wrong key class (live key on manage endpoint) | 401 | UNAUTHORIZED | Use atlas_mgmt_ key for management. |
| Key lacks scope | 403 | FORBIDDEN | Check key scopes in dashboard. |
| Entry not found | 404 | NOT_FOUND | get() returns null; others throw. |
| Rate limit exceeded | 429 | — | SDK auto-retries with backoff. |
| Validation error | 400 | VALIDATION_ERROR | Check err.errors for field-level details. |
See Errors for the full list of API status codes.
Current limitations
Draft preview not wrapped yet
The delivery client reads published content only. Previewing drafts requires a preview token from the admin side, which the SDK does not mint yet. Use GET /public/preview directly.
- Localizable fields are typed as their base value. The runtime locale merge still applies, but types don't distinguish per-locale shapes yet.