@latellu/atlas-agent-skills 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Work with Atlas CMS from Claude Code: bundles the Atlas MCP server (read/write content via tools) and the atlas-cms skill that teaches agents when and how to use the MCP tools, the @latellu/atlas-sdk delivery/management clients, and the @latellu/atlas-cli type generator.",
5
5
  "author": {
6
6
  "name": "Latellu",
package/README.md CHANGED
@@ -33,14 +33,18 @@ Then point your agent at the skill (via `AGENTS.md`, Cursor rules, etc.) and reg
33
33
  | Path | Purpose |
34
34
  | --- | --- |
35
35
  | `skills/atlas-cms/SKILL.md` | Router: shared Atlas concepts + decision table (MCP vs SDK vs CLI) |
36
- | `skills/atlas-cms/references/` | Per-surface guides: MCP tools, SDK delivery, SDK management, CLI typegen |
36
+ | `skills/atlas-cms/references/` | Per-surface guides: MCP tools, SDK delivery, SDK management, CLI typegen, content authoring (field formats, translations, blocks) |
37
37
  | `.mcp.json` | Atlas MCP server config (used by the Claude Code plugin) |
38
38
  | `.claude-plugin/` | Claude Code plugin + marketplace manifests |
39
39
  | `adapters/other-agents.md` | Setup for non-Claude agents |
40
40
 
41
- ## Releasing
41
+ ## Releasing (manual)
42
42
 
43
- Bump `version` in `package.json` (and `.claude-plugin/plugin.json`), tag `vX.Y.Z`, push the tag — CI publishes to npm. The Claude Code marketplace resolves the plugin from npm, so users pick up the new version on `claude plugin marketplace update latellu`.
43
+ 1. Bump `version` in `package.json` **and** `.claude-plugin/plugin.json` (keep them equal)
44
+ 2. `npm publish` (from an authenticated machine)
45
+ 3. `git tag vX.Y.Z && git push && git push --tags`
46
+
47
+ The Claude Code marketplace resolves the plugin from npm, so users pick up the new version on `claude plugin marketplace update latellu`.
44
48
 
45
49
  ## License
46
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@latellu/atlas-agent-skills",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent skill + MCP configuration for Atlas CMS. Works as a Claude Code plugin and as plain markdown guidance for any AI coding agent (Cursor, Codex, opencode, Gemini CLI, ...).",
5
5
  "license": "MIT",
6
6
  "author": "Latellu (https://github.com/tenriajeng)",
@@ -8,35 +8,65 @@ description: Use when integrating with or operating Atlas CMS - fetching entries
8
8
  A router, not a knowledge dump. Read the shared concepts below (always), then load
9
9
  **only** the reference file for the surface you are actually using.
10
10
 
11
- ## Shared concepts
11
+ ## Content model
12
12
 
13
- **Content model.** A *workspace* contains *content types* (schemas). A content type has
14
- *entries* (structured records with a `slug`, a `status`, and a `data` field map). A
15
- workspace also has *pages*: a page has SEO metadata and an ordered tree of *blocks*
16
- (`{ id, type, position, data }`). Localized content is delivered as base-locale data plus
17
- a `translations` array; readers merge a requested locale field-by-field with fallback to
18
- the base locale.
13
+ A **workspace** is the tenant it owns everything below and is reached by exactly one
14
+ API key (there is no workspace parameter on any endpoint or tool; scoping is implicit in
15
+ the key).
19
16
 
20
- **Two API key classes**, both sent as the `X-API-Key` header (never `Authorization`):
17
+ - **Content type** a schema (e.g. "Article") defining fields. Field types: `text`,
18
+ `textarea`, `richtext`, `number`, `boolean`, `date`, `select` (with options),
19
+ `image`, `relation`, `content_type_reference`.
20
+ - **Entry** — a record of a content type: `{ id, slug, status, published_at, data }`
21
+ where `data` is the field map. Lifecycle: `draft → published → archived`, plus
22
+ `scheduled` (publish at a future time).
23
+ - **Page** — like an entry but composed of ordered **blocks**
24
+ (`{ id, type, position, data }`) and carrying its own SEO object
25
+ (`title, description, keywords, og_image, canonical`).
26
+ - **Locale** — content is stored as base-locale data plus a `translations` array.
27
+ Readers merge the requested locale **field-by-field**; untranslated fields fall back
28
+ to the base locale. `image`/`relation` values are IDs/slugs, never resolved objects.
29
+
30
+ ## Authentication
31
+
32
+ Two key classes, both sent as the `X-API-Key` header (never `Authorization: Bearer`):
21
33
 
22
34
  | Key prefix | Plane | Base path | Can do |
23
35
  |---|---|---|---|
24
- | `atlas_live_*` | Delivery (read) | `/api/v1/public` | Read published entries, pages, media, schema |
36
+ | `atlas_live_*` | Delivery (read) | `/api/v1/public` | Read **published** entries, pages, media, schema |
25
37
  | `atlas_mgmt_*` | Management (write) | `/api/v1/manage` | Create/update/publish/delete content, upload media |
26
38
 
27
- - Keys are minted in the dashboard: `cms.atlas.latellu.com/dashboard/api-keys`.
39
+ - Keys are minted at `cms.atlas.latellu.com/dashboard/api-keys` (Developer → API Keys).
28
40
  - Default base URL: `https://api.atlas.latellu.com` (override only for self-hosted).
29
- - **Workspace scoping is implicit in the key.** There is no workspace parameter anywhere;
30
- a key reaches exactly one workspace.
31
- - Management keys act as a service actor bound to the RBAC permissions of the person who
32
- created them, further limited by scopes: `content:write`, `content:publish`,
33
- `media:write`. A missing scope returns 403 on that specific operation.
34
- - Production `atlas_live_*` keys only ever see **published** content. Draft content is
35
- not reachable through them, regardless of query parameters.
41
+ - The planes are strict: a live key gets 401 on `/manage/*`, a mgmt key gets 401 on
42
+ `/public/*`. A live key sees **only published content** — no query parameter reveals
43
+ drafts.
44
+ - Management keys act as a service actor bound to the RBAC permissions of whoever
45
+ created them, further limited by scopes: `content:write` (create/update/delete),
46
+ `content:publish` (publish/unpublish/archive/schedule), `media:write` (uploads).
47
+ A missing scope 403 on that operation only.
36
48
 
37
49
  **Safety rule:** never ship an `atlas_mgmt_*` key or the management client in
38
50
  browser-delivered code. Management usage is server-side only (API routes, scripts, CI).
39
51
 
52
+ ## Wire format (applies to every surface)
53
+
54
+ Every REST response is an envelope: `{ success, message, data?, meta?, code?, errors? }`.
55
+ The SDK and MCP unwrap it, but two payload quirks leak through in places: `entry.data`,
56
+ page `seo`, and block `data` are stored/transported as **JSON strings** (the SDK parses
57
+ them for you; MCP `get_page` does not), and list `meta` uses
58
+ `{ total_data, current_page, page_size, total_pages, next_cursor }`.
59
+
60
+ Error codes you will actually see: `UNAUTHORIZED` (401 — missing/empty/wrong-class key),
61
+ `FORBIDDEN` (403 — missing scope or RBAC), `NOT_FOUND` (404 — also returned for
62
+ other-workspace resources), `VALIDATION_ERROR` (400 — with field detail),
63
+ `PAGE_TOO_DEEP` (400 — offset pagination past page 1000), and 429 rate limiting
64
+ (back off exponentially; only the SDK management client retries for you).
65
+
66
+ Pagination limits: `limit` max 100, `page` max 1000; past that, switch to cursor
67
+ pagination (`meta.next_cursor`, opaque — never construct cursors yourself; `total_data`
68
+ is `-1` in cursor mode).
69
+
40
70
  ## Which surface do I use?
41
71
 
42
72
  | You are... | Use | Read |
@@ -45,11 +75,26 @@ browser-delivered code. Management usage is server-side only (API routes, script
45
75
  | Wanting typed content interfaces for the SDK | CLI type generator | [references/cli-typegen.md](references/cli-typegen.md) |
46
76
  | Writing content from your own code (scripts, migrations, backends) | SDK management client | [references/sdk-management.md](references/sdk-management.md) |
47
77
  | Operating content via MCP tools in this session (`list_entries`, `create_entry`, ...) | Atlas MCP server | [references/mcp.md](references/mcp.md) |
78
+ | Composing any `data` payload — field value formats, translations, page blocks | Authoring guide | [references/authoring.md](references/authoring.md) |
48
79
 
49
80
  Do **not** read references you don't need. If a task spans surfaces (e.g. "generate
50
81
  types, then build the page"), route each sub-task to its own single reference.
51
82
 
52
- ## Extending
83
+ Choosing between MCP and the management SDK for writes: MCP is for operating content
84
+ interactively in an agent session (one-off edits, content entry, audits); the SDK is for
85
+ code that outlives the session (migrations, sync jobs, backends) and for anything MCP
86
+ lacks (bulk, scheduling, page unpublish/archive, media delete, **writing translations**,
87
+ per-field validation errors).
88
+
89
+ ## Hard capability boundaries (don't go looking for these)
90
+
91
+ - **Schema is dashboard-only.** No API, SDK, CLI, or MCP surface creates content types,
92
+ fields, or block types. "Create a content type" = ask the user to do it in the
93
+ dashboard, then continue.
94
+ - **No field-value queries.** The public list endpoints filter by `type`/`locale` only —
95
+ "all articles where category = X" means paging through and filtering client-side.
96
+ - **The management plane is write-only** (no GET endpoints). Reading always needs a
97
+ live key; a write-then-verify flow therefore needs both keys.
53
98
 
54
99
  Deep API detail lives at https://docs.atlas.latellu.com — link there rather than
55
100
  duplicating endpoint documentation into this skill.
@@ -0,0 +1,125 @@
1
+ # Authoring valid content (field formats, translations, page blocks)
2
+
3
+ What actually gets validated on write, what each field type's value must look like, and
4
+ how to write localized content. Applies to both the management SDK and the MCP write
5
+ tools — read this before composing any `data` payload.
6
+
7
+ ## What the API validates (and what it silently accepts)
8
+
9
+ On entry create/update the backend enforces exactly four rules:
10
+
11
+ 1. `required` — the field must be present and non-empty. For **richtext**, the Tiptap
12
+ empty states `""`, `"<p></p>"`, `"<p><br></p>"` count as empty.
13
+ 2. `unique` — value must not collide with another entry of the workspace.
14
+ 3. `relation` fields — value must be a valid **entry UUID** (or array of UUIDs) that
15
+ exists in the workspace. Slugs are rejected.
16
+ 4. `image` fields — value must be a valid **media UUID** (or array) that exists in the
17
+ workspace. Upload first (`media.upload` / `upload_media`), then reference the
18
+ returned `id`.
19
+
20
+ **Everything else is stored as-is.** A number in a text field, a garbage date, a select
21
+ value outside the options — none of it is rejected. Validation failures you don't get
22
+ from the API become rendering bugs in the dashboard/frontend, so discipline comes from
23
+ you: fetch the schema first and match each field's type exactly.
24
+
25
+ ## Value format per field type
26
+
27
+ | Field type | Write this | Notes |
28
+ |---|---|---|
29
+ | `text`, `textarea` | plain string | |
30
+ | `richtext` | **Tiptap HTML string**, e.g. `"<p>Hello <strong>world</strong></p>"` | not markdown, not JSON; the dashboard editor is Tiptap |
31
+ | `number` / `boolean` | JSON number / boolean | not validated — don't send strings |
32
+ | `date` | ISO 8601 string, `"2026-08-01"` (or full timestamp) | not format-validated; ISO keeps the dashboard picker working |
33
+ | `select` | one of the schema's `options`, exact string | not validated — off-list values break the generated literal-union types |
34
+ | `image` | media UUID string, or array of them | validated against workspace media |
35
+ | `relation`, `content_type_reference` | entry UUID string, or array | validated against workspace entries; **UUID, not slug** |
36
+
37
+ Reading back: the value is whatever was stored. Content created through this API holds
38
+ UUIDs for image/relation; older or imported datasets may hold URLs/slugs — when
39
+ rendering, branch on the shape (e.g. `value.startsWith("http")` → use directly,
40
+ otherwise resolve via `media.get(id)`).
41
+
42
+ ## Writing translations (localized entries)
43
+
44
+ Entry create/update accept a `translations` map alongside `data`:
45
+
46
+ ```jsonc
47
+ {
48
+ "slug": "hello",
49
+ "data": { "title": "Hello", "views": 10 }, // base locale
50
+ "translations": {
51
+ "de": { "data": { "title": "Hallo" } }, // only localizable fields
52
+ "ja": { "data": { "title": "こんにちは" } }
53
+ }
54
+ }
55
+ ```
56
+
57
+ Rules that will bite you if unknown:
58
+
59
+ - **A `required` + `localizable` field is validated against `translations`, not
60
+ `data`** — it must have a non-empty value in at least one translation locale, or the
61
+ write fails with "required localizable field ... is missing".
62
+ - Translation `data` should contain **only localizable fields**; non-localizable values
63
+ live in base `data` and fall back automatically on read.
64
+ - **Via the management SDK**: pass `translations` as an extra key on
65
+ `CreateEntryInput`/`UpdateEntryInput` (the input types accept extra keys):
66
+
67
+ ```ts
68
+ await client.entries("article").update("hello", {
69
+ data: fullBaseData, // full replace — include everything
70
+ translations: { de: { data: { title: "Hallo" } } }, // also a full replace per locale
71
+ });
72
+ ```
73
+
74
+ - **Via MCP: not possible.** The `create_entry`/`update_entry` tools forward only
75
+ `slug` and `data` — a `translations` key is dropped. Translating content needs the
76
+ SDK or the dashboard; say so instead of trying.
77
+
78
+ ## Writing pages and blocks
79
+
80
+ The real write shape (`POST/PUT /pages`) differs from what the reading API shows:
81
+
82
+ ```jsonc
83
+ {
84
+ "slug": "landing", // required on create; there is NO "title" field —
85
+ "seo": { // the page's title lives in seo.title
86
+ "title": "Landing",
87
+ "description": "...",
88
+ "keywords": ["a", "b"], // array of strings
89
+ "og_image": "https://...",
90
+ "canonical": "https://..."
91
+ },
92
+ "seo_translations": { "de": { "title": "Landung", ... } },
93
+ "blocks": [
94
+ {
95
+ "block_type_id": "<UUID>", // required per block — see warning below
96
+ "parent_id": null, // block UUID for nesting, or null
97
+ "position": 0,
98
+ "data": { "...": "block fields" },
99
+ "translations": { "de": { "data": { "...": "..." } } }
100
+ }
101
+ ]
102
+ }
103
+ ```
104
+
105
+ - `update` with `blocks` replaces the block list; reordering alone has a dedicated
106
+ endpoint (`blocksReorder(slug, ids)` in the SDK).
107
+ - **`block_type_id` is effectively dashboard-only knowledge.** The public read API
108
+ returns each block's type *name* and its instance id — not its `block_type_id` — and
109
+ the management plane has no read endpoints at all. To compose new blocks
110
+ programmatically you must be given the block-type UUIDs (from the dashboard). If you
111
+ don't have them, structure the content as **entries** instead (fully writable blind)
112
+ and keep pages for dashboard-managed layout.
113
+ - MCP `create_page`/`update_page` extra caveats: the `title` argument is ignored by the
114
+ backend (set `seo.title`), `seo` there accepts only `title`/`description`, and block
115
+ `translations`/`seo_translations` aren't expressible.
116
+
117
+ ## Practical write checklist
118
+
119
+ 1. `get_content_type` / schema → exact field names, types, required, localizable, select options.
120
+ 2. Uploads first → collect media UUIDs.
121
+ 3. Compose `data` per the table above; localizable required fields go in `translations`.
122
+ 4. Create (draft) → read back → verify → publish.
123
+ 5. On `VALIDATION_ERROR` via MCP (no field detail): re-check step 1-3 against this file
124
+ before retrying — the usual culprits are slug-instead-of-UUID in relation/image,
125
+ markdown in richtext, and required-localizable fields sitting in `data`.
@@ -1,51 +1,93 @@
1
1
  # CLI type generation
2
2
 
3
- Generates TypeScript interfaces for a workspace from `/api/v1/public/schema`.
3
+ `@latellu/atlas-cli` generates TypeScript interfaces for a workspace by fetching
4
+ `GET /api/v1/public/schema` (workspace derived server-side from the key). One command:
4
5
 
5
6
  ```bash
6
7
  npx @latellu/atlas-cli generate --api-key=atlas_live_xxx --output=./lib/atlas.types.ts
7
- # or, installed globally, the binary is named atlas-cms (not atlas-cli):
8
+ # installed globally the binary is named `atlas-cms` (not atlas-cli):
8
9
  atlas-cms generate --api-key=atlas_live_xxx
9
10
  ```
10
11
 
11
12
  | Flag | Env fallback | Default |
12
13
  |---|---|---|
13
- | `--api-key` | `ATLAS_API_KEY` | (required) |
14
+ | `--api-key` | `ATLAS_API_KEY` | (required — errors if absent from both) |
14
15
  | `--url` | `ATLAS_API_URL` | `https://api.atlas.latellu.com` |
15
16
  | `--output` | `ATLAS_OUTPUT` | `./src/atlas.types.ts` |
16
17
 
17
- Flags win over env vars. If `--output` doesn't end in `.ts` it is treated as a directory
18
- and `atlas.types.ts` is appended. The workspace is derived server-side from the key.
18
+ Flags win over env vars. Both `--key=value` and `--key value` forms work. If `--output`
19
+ doesn't end in `.ts` it is treated as a directory and `atlas.types.ts` is appended.
20
+ Parent directories are created automatically. Output is deterministic for a given
21
+ schema, so committing the file produces clean diffs on schema changes.
19
22
 
20
23
  ## Output anatomy
21
24
 
22
- - One `export interface <PascalCaseSlug>` per content type, plus
23
- `export type Locale = "en" | "ja" | ...` from the workspace locales.
24
- - A registry mapping slugs to interfaces — feed it to the SDK for full typing:
25
+ ```ts
26
+ // Generated by @latellu/atlas-cli DO NOT EDIT BY HAND.
27
+ // Workspace: acme-digital
28
+ // Source: https://api.atlas.latellu.com/api/v1/public/schema
29
+
30
+ export type Locale = "en" | "de" | "ja"; // from workspace locales ("string" if none)
31
+
32
+ /** News Article (content type: "news-article") */
33
+ export interface NewsArticle {
34
+ title: string; // localizable
35
+ category: "News" | "Event"; // select field → literal union
36
+ views?: number; // non-required → optional
37
+ cover_image: string; // image field → media ID (unresolved)
38
+ "og:title"?: string; // non-identifier names are quoted
39
+ }
40
+
41
+ export interface AtlasContentTypes {
42
+ "news-article": NewsArticle;
43
+ // ... one key per content-type slug
44
+ }
45
+ ```
46
+
47
+ Consume it as the SDK generic — this is the whole point:
25
48
 
26
49
  ```ts
27
50
  import { createClient } from "@latellu/atlas-sdk";
28
51
  import type { AtlasContentTypes } from "./atlas.types";
29
52
 
30
53
  const atlas = createClient<AtlasContentTypes>({ url, apiKey });
31
- const article = await atlas.entries("news-article").get("hello"); // data is typed
54
+ const article = await atlas.entries("news-article").get("hello");
55
+ article?.data.category; // "News" | "Event" — fully typed
32
56
  ```
33
57
 
34
- ## Type semantics (what the generated types do and don't promise)
58
+ ## Field-type mapping (what the types do and don't promise)
59
+
60
+ | Atlas field type | Generated TS | Caveat |
61
+ |---|---|---|
62
+ | `text`, `textarea`, `richtext`, `date` | `string` | richtext is the raw stored string |
63
+ | `number` / `boolean` | `number` / `boolean` | |
64
+ | `select` (with options) | `"A" \| "B"` literal union | no options → `string` |
65
+ | `image` | `string` | **media ID** — resolve via `atlas.media.get(id)` |
66
+ | `relation`, `content_type_reference` | `string` | **entry slug/ID** — resolve via `atlas.entries(type).get(slug)` |
67
+ | localizable anything | base type + `// localizable` comment | value is base-locale only; no per-locale map in this release |
68
+ | unknown/new field type | `unknown` | codegen never fails on new backend field types |
69
+ | content type with zero fields | `{ [key: string]: unknown }` | index-signature escape hatch |
70
+ | non-required field | `name?:` | |
71
+
72
+ ## Workflow
73
+
74
+ - Add a script so regeneration is one command, and **regenerate after every schema
75
+ change** in the dashboard (the file is generated — never hand-edit):
76
+
77
+ ```json
78
+ { "scripts": { "atlas:types": "atlas-cli generate --output=./lib/atlas.types.ts" } }
79
+ ```
35
80
 
36
- - `select` fields with options string-literal unions (`"News" | "Event"`).
37
- - `number` `number`, `boolean` `boolean`; `text`/`textarea`/`richtext`/`date` `string`.
38
- - **`image`, `relation`, `content_type_reference` plain `string`** the raw media ID or
39
- entry slug, never a resolved object. Resolve manually via `atlas.media.get(id)` /
40
- `atlas.entries(type).get(slug)`.
41
- - **Localizable fields are only marked with a `// localizable` comment**; the value is
42
- typed as the base-locale scalar (no per-locale map in this release).
43
- - Optional (`?`) mirrors non-required fields; non-identifier field names are quoted.
44
- - A content type with zero fields renders as `{ [key: string]: unknown }`; unknown field
45
- types degrade to `unknown` instead of failing codegen.
81
+ with `ATLAS_API_KEY` in the environment (e.g. `.env.local`, not committed).
82
+ - Stale types don't fail at runtime the SDK sends whatever the API returns — they
83
+ fail at compile time or silently type new fields as missing. Treat type/`data`
84
+ mismatches as a "regenerate first" signal before debugging anything else.
46
85
 
47
- ## Rules
86
+ ## Troubleshooting
48
87
 
49
- - The file is generated (`// Generated by @latellu/atlas-cli — DO NOT EDIT BY HAND.`) —
50
- never hand-edit; **regenerate after every schema change** in the dashboard.
51
- - A 401 means the API key is wrong or revoked (the error says so); check `--api-key`.
88
+ | Symptom | Cause |
89
+ |---|---|
90
+ | `401 ... (check your --api-key)` | wrong/revoked key, or a `atlas_mgmt_` key (schema needs a live key) |
91
+ | interface is `{ [key: string]: unknown }` | that content type has no fields defined yet |
92
+ | field typed `unknown` | backend field type newer than the installed CLI — upgrade `@latellu/atlas-cli` |
93
+ | exit code 1 with usage text | ran without a subcommand — the only command is `generate` |
@@ -1,63 +1,110 @@
1
1
  # Atlas MCP tools
2
2
 
3
- The plugin bundles the `@latellu/atlas-mcp` server (stdio). Read tools need
4
- `ATLAS_LIVE_API_KEY`; write tools need `ATLAS_MGMT_API_KEY`. With only one key set, the
5
- other half of the tools fails client-side naming the missing variable. If neither is set
6
- the server exits at startup. `ATLAS_API_URL` overrides the base URL for self-hosted.
3
+ The `@latellu/atlas-mcp` server (stdio) exposes Atlas content operations as agent tools.
4
+
5
+ **Environment** read tools need `ATLAS_LIVE_API_KEY`, write tools need
6
+ `ATLAS_MGMT_API_KEY`; with only one set, the other half of the tools fails client-side
7
+ naming the missing variable, and with neither set the server exits at startup.
8
+ `ATLAS_API_URL` overrides the base URL (self-hosted). `MCP_ALLOWED_UPLOAD_PATHS`
9
+ (comma-separated absolute dirs) restricts where `upload_media` may read files from.
10
+
11
+ Tool results are the API's `data` payload as pretty-printed JSON text; failures come
12
+ back as `Error: Atlas API error: <status> - <message>` tool results (not protocol
13
+ errors).
7
14
 
8
15
  ## Tool catalog
9
16
 
10
- **Schema & discovery** (live key)
11
- - `get_workspace_schema` — workspace meta (slug, locales, default locale) + all content types.
12
- - `list_content_types` / `get_content_type(content_type)` convenience views over the schema.
17
+ ### Schema & discovery live key
18
+
19
+ | Tool | Params | Returns |
20
+ |---|---|---|
21
+ | `get_workspace_schema` | — | `{ workspace: { slug, name, locales, default_locale }, content_types: [...] }` |
22
+ | `list_content_types` | — | `content_types` array (client-side view of the schema) |
23
+ | `get_content_type` | `content_type` | one content type with its full field list; error text if the slug doesn't exist |
24
+
25
+ ### Entries — read: live key · write: mgmt key
26
+
27
+ | Tool | Params | Notes |
28
+ |---|---|---|
29
+ | `list_entries` | `content_type`, `page?`=1, `limit?`=20, `status?` | `status` is free text and **cannot** reveal drafts to a live key |
30
+ | `get_entry` | `content_type`, `slug` | resolves by `slug` alone — `content_type` is accepted but not sent |
31
+ | `create_entry` | `content_type`, `data`, `slug?` | creates a **draft**; slug auto-generated when omitted |
32
+ | `update_entry` | `content_type`, `slug`, `data` | **full replace** of `data`, not a patch |
33
+ | `publish_entry` / `unpublish_entry` / `archive_entry` | `content_type`, `slug` | need the `content:publish` scope |
34
+ | `duplicate_entry` | `content_type`, `slug` | copies to a new draft |
35
+ | `delete_entry` | `content_type`, `slug` | returns plain text, not JSON |
36
+
37
+ ### Pages — read: live key · write: mgmt key
38
+
39
+ | Tool | Params | Notes |
40
+ |---|---|---|
41
+ | `list_pages` | `page?`=1, `limit?`=20 | summaries, no blocks |
42
+ | `get_page` | `slug` | block tree — each block's `data` is a **JSON string**, parse it |
43
+ | `create_page` | `title`, `slug?`, `blocks?`, `seo?` | draft; `seo` accepts only `title`/`description` here |
44
+ | `update_page` | `slug`, `title?`, `blocks?`, `seo?` | |
45
+ | `publish_page` | `slug` | the ONLY page lifecycle tool — see gaps below |
46
+ | `delete_page` | `slug` | plain-text result |
47
+
48
+ ### Media
49
+
50
+ | Tool | Params | Notes |
51
+ |---|---|---|
52
+ | `get_media` | `id` | live key |
53
+ | `upload_media` | `file_path` (absolute), `folder?`="/", `alt_text?` | mgmt key; reads the local file, multipart upload |
54
+
55
+ ## Recommended write workflow
13
56
 
14
- **Entries** (read: live key; write: mgmt key)
15
- - `list_entries(content_type, page?, limit?, status?)`
16
- - `get_entry(content_type, slug)`
17
- - `create_entry(content_type, data, slug?)` creates a **draft**; slug auto-generated if omitted.
18
- - `update_entry(content_type, slug, data)`
19
- - `publish_entry` / `unpublish_entry` / `archive_entry` / `duplicate_entry` / `delete_entry` — all `(content_type, slug)`.
57
+ 1. **Schema first.** Call `get_content_type` (or `get_workspace_schema`) before any
58
+ `create_entry`/`update_entry`. The `data` argument is an unvalidated map; the backend
59
+ rejects wrong shapes with 400, and **per-field validation detail is lost through
60
+ MCP** you only see `Atlas API error: 400 - validation failed`. Knowing the exact
61
+ field names/types up front is the difference between one call and five.
62
+ 2. **Read-modify-write for updates.** `update_entry` replaces `data` wholesale:
63
+ `get_entry` → merge your change into the full object → `update_entry` with everything.
64
+ 3. **Create → verify → publish.** `create_entry` makes a draft; `publish_entry` needs
65
+ the `content:publish` scope (403 without it, with a clear message).
66
+ 4. **Before `upload_media`**: check the file is ≤ 10 MB and has a known extension.
67
+ Validation is entirely server-side; MIME is inferred from the extension and unknown
68
+ extensions upload as `application/octet-stream`, which the backend always rejects
69
+ (so `.svg`, `.heic` fail). Allowed: `image/*`, `video/*`, `application/pdf` (i.e.
70
+ `.jpg .jpeg .png .gif .webp .mp4 .webm .mov .pdf`).
20
71
 
21
- **Pages** (read: live; write: mgmt)
22
- - `list_pages(page?, limit?)` / `get_page(slug)`
23
- - `create_page(title, slug?, blocks?, seo?)` — creates a draft. `seo` accepts `title`/`description` only.
24
- - `update_page(slug, title?, blocks?, seo?)` / `publish_page(slug)` / `delete_page(slug)`
72
+ ## Capability gaps route elsewhere instead of retrying
25
73
 
26
- **Media**
27
- - `get_media(id)` (live key)
28
- - `upload_media(file_path, folder?, alt_text?)` (mgmt key) — reads a local file and uploads it.
74
+ These exist in the backend/SDK but have **no MCP tool**; say so and point to the
75
+ dashboard or the management SDK (`sdk-management.md`) rather than improvising:
29
76
 
30
- ## Recommended workflow for writes
77
+ - **Translations cannot be written.** `create_entry`/`update_entry` forward only
78
+ `slug`/`data` — a `translations` key is dropped. "Translate this entry" is an SDK or
79
+ dashboard job (see `authoring.md`), and note that a required+localizable field can
80
+ make MCP creates fail outright (its value must be in `translations`).
81
+ - Page lifecycle beyond publish: **no `unpublish_page`, `archive_page`,
82
+ `duplicate_page`**.
83
+ - **No** bulk operations, entry/page **scheduling**, or block **reorder** tools.
84
+ - **No `delete_media` / `update_media`** (alt-text edits) — media is upload-and-read
85
+ only through MCP.
86
+ - Pagination is offset-only (`page`/`limit`); there is no cursor option, so listings
87
+ past page 1000 (`PAGE_TOO_DEEP`) are unreachable via MCP.
88
+ - Composing page **blocks** needs `block_type_id` UUIDs that no read surface exposes
89
+ (dashboard-only knowledge) — prefer entries for agent-authored content. Also:
90
+ `create_page`'s `title` argument is ignored by the backend (the title is `seo.title`),
91
+ and its `seo` accepts only `title`/`description`.
31
92
 
32
- 1. Call `get_content_type` (or `get_workspace_schema`) first to learn the exact field
33
- names and types. `data` is an unvalidated map the backend rejects bad shapes with a
34
- 400, and per-field validation detail is **not** surfaced through MCP (you only see
35
- `Atlas API error: 400 - validation failed`).
36
- 2. `create_entry` / `update_entry` with a `data` object matching the schema.
37
- 3. `publish_entry` when ready (requires the `content:publish` scope on the key).
93
+ Before composing any `data` payload, read `authoring.md` it defines the exact value
94
+ format per field type (richtext = Tiptap HTML, relation/image = UUIDs, etc.), which
95
+ matters double here because MCP swallows per-field validation errors.
38
96
 
39
97
  ## Gotchas
40
98
 
41
- - **`update_entry` is a full replace** of `data`, not a patch. Read the entry first,
42
- merge your change, then send the complete object (read-modify-write).
43
- - **Page lifecycle is asymmetric**: there is no `unpublish_page`, `archive_page`, or
44
- `duplicate_page` tool. There are also no bulk, schedule, or block-reorder tools, and no
45
- `delete_media`/`update_media`. For those, use the dashboard or the SDK management
46
- client (see `sdk-management.md`).
47
- - **`get_page` returns each block's `data` as a JSON-encoded string**, not an object.
48
- Parse it before reading fields otherwise blocks look empty.
49
- - **`get_entry` resolves by slug alone**; its `content_type` argument is accepted but not
50
- sent to the backend. Don't rely on it to disambiguate slugs shared across types.
51
- - **Drafts are invisible to live keys.** `list_entries(status="draft")` with a production
52
- key returns nothing — that is key scoping, not an empty workspace.
53
- - **Uploads are validated server-side only**: max 10 MB; MIME must be `image/*`,
54
- `video/*`, or `application/pdf`. MIME is inferred from the file extension; unknown
55
- extensions become `application/octet-stream` and are always rejected (e.g. `.svg`,
56
- `.heic`). Check size and extension before calling `upload_media`. If
57
- `MCP_ALLOWED_UPLOAD_PATHS` is set, `file_path` must live under one of those directories.
58
- - **No client-side retry on 429.** On `Atlas API error: 429 - ...`, back off
59
- (exponentially) and retry yourself.
60
- - **`delete_entry` / `delete_page` return plain text** ("Entry 'x' deleted successfully"),
61
- unlike every other tool, which returns JSON.
62
- - Every write automatically sends a fresh `Idempotency-Key` header per call; identical
63
- retries you issue as *new tool calls* are new operations, not deduplicated.
99
+ - **Drafts are invisible to live keys** `list_entries(status="draft")` returning
100
+ empty means key scoping, not an empty workspace.
101
+ - **`get_page` block `data` needs `JSON.parse`** per block, or blocks look empty.
102
+ - **`get_entry` ignores `content_type`** slugs shared across types resolve to
103
+ whatever the backend finds by slug.
104
+ - **No client-side 429 retry** — on `Atlas API error: 429`, back off exponentially and
105
+ retry the tool call yourself.
106
+ - Every write sends a fresh auto-generated `Idempotency-Key` per call, so re-issuing a
107
+ failed create as a *new tool call* is a new operation check whether the first call
108
+ actually landed (e.g. `get_entry`) before retrying creates.
109
+ - One MCP server = one workspace (the key's). Multi-workspace work needs one server
110
+ registration per workspace.
@@ -1,82 +1,185 @@
1
1
  # SDK delivery client (read)
2
2
 
3
+ `@latellu/atlas-sdk` — typed, zero-dependency read client for `/api/v1/public`.
4
+ Node 18+ (uses global `fetch`). ESM only.
5
+
3
6
  ```bash
4
- npm install @latellu/atlas-sdk # Node 18+ (uses global fetch)
7
+ npm install @latellu/atlas-sdk
8
+ ```
9
+
10
+ ## Client setup
11
+
12
+ ```ts
13
+ import { createClient } from "@latellu/atlas-sdk";
14
+
15
+ const atlas = createClient<AtlasContentTypes>({
16
+ url: "https://api.atlas.latellu.com", // required — SDK reads NO env vars itself
17
+ apiKey: "atlas_live_...", // required — delivery key
18
+ fetchImpl: customFetch, // optional — see "Caching" below
19
+ });
5
20
  ```
6
21
 
7
- ## Canonical setup (Next.js thin adapter)
22
+ - `createClient` **throws synchronously** (`AtlasError`) if `url` or `apiKey` is missing.
23
+ - The generic `<AtlasContentTypes>` comes from CLI codegen (`cli-typegen.md`); without
24
+ it, `entry.data` is `Record<string, unknown>`.
25
+ - All requests go to `${url}/api/v1/public/*` with header `X-API-Key`.
8
26
 
9
- Keep one server-only adapter, e.g. `lib/atlas.ts`:
27
+ ### Canonical Next.js thin adapter
28
+
29
+ Keep one server-only adapter (pattern used by all official Atlas examples), e.g.
30
+ `lib/atlas.ts`:
10
31
 
11
32
  ```ts
12
33
  import "server-only";
13
34
  import { createClient } from "@latellu/atlas-sdk";
14
- import type { AtlasContentTypes } from "./atlas.types"; // generated — see cli-typegen.md
35
+ import type { AtlasContentTypes } from "./atlas.types";
15
36
 
16
- // The SDK sets NO cache directive. In Next.js you must decide caching yourself
17
- // via a custom fetchImpl, or Next's defaults for un-annotated fetch apply.
18
37
  const noStoreFetch: typeof fetch = (input, init) =>
19
- fetch(input, { ...init, cache: "no-store" }); // or next: { revalidate: 60 }
38
+ fetch(input, { ...init, cache: "no-store" });
20
39
 
21
40
  export const atlas = createClient<AtlasContentTypes>({
22
- url: process.env.ATLAS_BASE_URL ?? "", // https://api.atlas.latellu.com
23
- apiKey: process.env.ATLAS_API_KEY ?? "", // atlas_live_...
41
+ url: process.env.ATLAS_BASE_URL ?? "",
42
+ apiKey: process.env.ATLAS_API_KEY ?? "",
24
43
  fetchImpl: noStoreFetch,
25
44
  });
26
45
  ```
27
46
 
28
- With `no-store`, pair route files with `export const dynamic = "force-dynamic"` so
29
- dashboard edits appear immediately. The SDK reads no env vars itself — `url`/`apiKey`
30
- must be passed explicitly. `createClient` throws synchronously if either is missing.
47
+ ### Caching the SDK deliberately has no knob
48
+
49
+ The SDK issues plain `fetch(url, { headers })` with **no cache directive**. In Next.js
50
+ App Router this means caching behavior is whatever Next's defaults do for un-annotated
51
+ fetch (varies by version/runtime). Decide explicitly via `fetchImpl`:
31
52
 
32
- ## Surface
53
+ | Goal | fetchImpl |
54
+ |---|---|
55
+ | Dashboard edits appear immediately (no SSG) | `cache: "no-store"` + `export const dynamic = "force-dynamic"` in routes |
56
+ | ISR-style freshness | `next: { revalidate: 60 }` |
57
+ | Astro/SSR outside Next | default fetch is fine (no framework cache layer) |
58
+
59
+ ## Full surface
33
60
 
34
61
  ```ts
35
- const { items, total, page, pageSize } =
36
- await atlas.entries("news-article").list({ locale: "en", page: 1, limit: 20, sort: "created_at:desc" });
62
+ interface AtlasClient<TSchema> {
63
+ entries<K extends keyof TSchema>(type: K): EntriesResource<TSchema[K]>;
64
+ pages: PagesResource;
65
+ media: MediaResource;
66
+ raw: Requester; // low-level escape hatch
67
+ }
68
+ ```
37
69
 
38
- const entry = await atlas.entries("news-article").get("hello-world", { locale: "en" }); // null on 404
39
- // entry: { id, slug, status, published_at, data } — data is already parsed to an object
70
+ ### Entries
40
71
 
41
- const pages = await atlas.pages.list({ locale: "en", page: 1, limit: 20 }); // summaries, no blocks
42
- const pageDetail = await atlas.pages.get("home", { locale: "en" }); // null on 404
43
- // pageDetail: { id, slug, status, seo, blocks: [{ id, type, position, data }] }
72
+ ```ts
73
+ const res = await atlas.entries("news-article").list({
74
+ locale: "en", // optional
75
+ page: 1, limit: 20, // offset pagination (limit max 100, page max 1000)
76
+ sort: "created_at:desc", // "<field>:<asc|desc>", e.g. "published_at:desc"
77
+ });
78
+ // res: ListResult<AtlasEntry<T>> = { items, total, page, pageSize }
44
79
 
45
- const asset = await atlas.media.get(mediaId); // null on 404
46
- const raw = await atlas.raw.get<T>("/some/path"); // escape hatch for unwrapped /api/v1/public paths
80
+ const entry = await atlas.entries("news-article").get("hello-world", { locale: "en" });
81
+ // AtlasEntry<T> = { id, slug, status, published_at: string | null, data: T }
82
+ // null on 404; throws AtlasError on anything else
47
83
  ```
48
84
 
49
- Semantics the SDK handles for you (don't re-implement):
50
- - `entry.data`, page `seo`, and block `data` arrive from the API as JSON strings the
51
- SDK parses them.
52
- - Locale merge is field-by-field: translated fields overlay base data; untranslated
53
- fields fall back to the base locale.
54
- - Page blocks are always returned sorted by `position`, regardless of API order.
85
+ What the SDK does for you here (do not re-implement):
86
+ - `data` arrives from the API as a **JSON string**; the SDK parses it to an object
87
+ (empty string → `{}`).
88
+ - With `locale`, `get()` merges the matching `translations[].data` over the base `data`
89
+ field-by-field (`{ ...base, ...translation }`) — untranslated fields keep base values.
90
+
91
+ ### Pages
92
+
93
+ ```ts
94
+ const list = await atlas.pages.list({ locale: "en", page: 1, limit: 20 });
95
+ // items: AtlasPageSummary[] — lightweight rows { id, slug, status, ... }, NO blocks
96
+
97
+ const page = await atlas.pages.get("home", { locale: "en" }); // null on 404
98
+ // AtlasPage = { id, slug, status, seo: AtlasPageSEO, blocks: AtlasBlock[] }
99
+ // AtlasPageSEO = { title?, description?, keywords?, og_image?, canonical?, ... }
100
+ // AtlasBlock = { id, type, position, data: Record<string, unknown> }
101
+ ```
55
102
 
56
- Errors: non-404 failures throw `AtlasError` (`{ status?, code?, message }`). `get()`
57
- methods return `null` only for 404.
103
+ `pages.get` semantics: SEO is overlaid with `seo_translations` for the locale
104
+ (field-level fallback); each block's `data` is overlaid with its matching
105
+ `block_translations` row; **blocks are always returned sorted by `position`** regardless
106
+ of API order; `seo`/block `data` JSON strings are parsed defensively (malformed JSON
107
+ silently becomes `{}` — a corrupt block fails silent, not loud).
108
+
109
+ ### Media & raw
110
+
111
+ ```ts
112
+ const asset = await atlas.media.get(id); // MediaAsset = { id, ...unknown } | null on 404
113
+
114
+ const { data, meta } = await atlas.raw.get<T>("/entries", { type: "article", cursor });
115
+ // any /api/v1/public path; use for things the SDK doesn't wrap — e.g. CURSOR pagination
116
+ // (entries.list only exposes page/limit; past page 1000 the API returns PAGE_TOO_DEEP,
117
+ // so deep datasets must iterate meta.next_cursor via raw.get)
118
+ ```
119
+
120
+ ## Error handling
121
+
122
+ ```ts
123
+ import { AtlasError } from "@latellu/atlas-sdk";
124
+
125
+ try {
126
+ const items = await atlas.entries("article").list();
127
+ } catch (e) {
128
+ if (e instanceof AtlasError) {
129
+ e.status; // HTTP status (undefined for network errors)
130
+ e.code; // "UNAUTHORIZED" | "FORBIDDEN" | "PAGE_TOO_DEEP" | ...
131
+ }
132
+ }
133
+ ```
134
+
135
+ Only `get()` methods special-case 404 → `null`. `list()` never returns null. Network
136
+ failures are wrapped in `AtlasError` with the URL in the message.
58
137
 
59
138
  ## Rendering blocks
60
139
 
61
- Dispatch on `block.type` with an explicit fallback so new block types added in the
62
- dashboard never crash the site:
140
+ Dispatch on `block.type` with an explicit fallback so block types added later in the
141
+ dashboard never crash the site — adding a section type then only requires registering a
142
+ component here:
63
143
 
64
144
  ```tsx
65
145
  export function BlockRenderer({ blocks }: { blocks: AtlasBlock[] }) {
66
146
  return blocks.map((block) => {
67
147
  switch (block.type) {
68
- case "hero": return <HeroBlock key={block.id} data={block.data} />;
69
- case "faq": return <FaqBlock key={block.id} data={block.data} />;
70
- default: return null; // or an <UnknownBlock /> placeholder
148
+ case "hero": return <HeroBlock key={block.id} data={block.data} />;
149
+ case "faq": return <FaqBlock key={block.id} data={block.data} />;
150
+ default: return <UnknownBlock key={block.id} type={block.type} />; // never throw
71
151
  }
72
152
  });
73
153
  }
74
154
  ```
75
155
 
76
- ## Limits
156
+ ## Resolving reference fields
157
+
158
+ `image` / `relation` / `content_type_reference` values are raw strings, never resolved
159
+ objects. Content written through the API holds **UUIDs** (that's what the backend
160
+ validates); imported/seeded datasets may hold URLs instead — branch on the shape:
161
+
162
+ ```ts
163
+ const article = await atlas.entries("article").get(slug);
164
+
165
+ const cover = article.data.cover_image.startsWith("http")
166
+ ? article.data.cover_image // legacy/imported: direct URL
167
+ : await atlas.media.get(article.data.cover_image); // API-written: media UUID
168
+
169
+ // relation fields hold entry UUIDs; the public API fetches entries by SLUG only,
170
+ // so resolve relations by listing the target type and matching on id:
171
+ const authors = await atlas.entries("author").list({ limit: 100 });
172
+ const author = authors.items.find((a) => a.id === article.data.author);
173
+ ```
174
+
175
+ Also note: list endpoints have **no field-value filters** (only type/locale/page/sort) —
176
+ "entries where category = X" means paging + filtering client-side.
177
+
178
+ ## Limits to remember
77
179
 
78
- - Draft preview is not yet supported by the SDK — delivery keys see published content only.
79
- - There is no `search` or `schema` namespace on the client; schema introspection is the
80
- CLI's job (`cli-typegen.md`).
81
- - `image` / `relation` / `content_type_reference` field values are raw string IDs/slugs —
82
- resolve with `atlas.media.get(id)` or `atlas.entries(type).get(slug)`.
180
+ - Delivery keys see **published content only**; draft preview is not yet supported by
181
+ the SDK (preview-token endpoint pending).
182
+ - No `search` or `schema` namespace on the client — schema introspection belongs to the
183
+ CLI (`cli-typegen.md`); anything else goes through `raw.get`.
184
+ - API keys are rate limited; a 429 here is **not** retried automatically (only the
185
+ management client retries) — back off exponentially and retry yourself.
@@ -1,6 +1,7 @@
1
1
  # SDK management client (write)
2
2
 
3
- Server-side only. Never import this module or expose an `atlas_mgmt_*` key in
3
+ `@latellu/atlas-sdk/management` Promise-based write client for `/api/v1/manage`.
4
+ **Server-side only.** Never import this module or expose an `atlas_mgmt_*` key in
4
5
  browser-bundled code.
5
6
 
6
7
  ```ts
@@ -12,49 +13,136 @@ const client = createManagementClient<AtlasContentTypes>({
12
13
  });
13
14
  ```
14
15
 
15
- `createManagementClient` throws `ManagementConfigError` **synchronously** if the token
16
- doesn't start with `atlas_mgmt_` (e.g. you passed a live key) — wrap construction in
17
- try/catch, not just the calls. Requests go to `/api/v1/manage` with `X-API-Key`.
16
+ Construction rules:
17
+ - The token prefix is validated **synchronously**: a non-`atlas_mgmt_` token (e.g. a
18
+ live key) throws `ManagementConfigError` immediately wrap
19
+ `createManagementClient(...)` in try/catch, not just the awaited calls.
20
+ - Requests carry `X-API-Key` and go to `${url}/api/v1/manage/*`.
21
+ - The key's scopes gate operations: `content:write` → create/update/delete/duplicate,
22
+ `content:publish` → publish/unpublish/archive/schedule, `media:write` → uploads.
23
+ Missing scope = 403 on that call only. Every write is audit-attributed to the key's
24
+ creator and bounded by their RBAC — a key can never do more than its creator could in
25
+ the dashboard.
18
26
 
19
- ## Surface
27
+ ## Entries
20
28
 
21
29
  ```ts
22
30
  const w = client.entries("news-article");
23
- await w.create({ slug: "hello", data: { title: "Hello" } });
24
- await w.update("hello", { data: { title: "Hello v2" } });
25
- await w.publish("hello"); // needs content:publish scope
26
- await w.unpublish("hello");
27
- await w.archive("hello");
28
- await w.schedule("hello", "2026-08-01T09:00:00Z");
29
- await w.duplicate("hello");
30
- await w.delete("hello");
31
- await w.bulk([{ op: "publish", id: "..." }, ...]);
32
-
33
- await client.pages.create({ slug: "landing", /* ... */ });
34
- await client.pages.update("landing", { /* ... */ });
35
- await client.pages.publish("landing"); // also: unpublish, archive, schedule, delete
36
- await client.pages.blocksReorder("landing", ["block-id-1", "block-id-2"]);
37
-
38
- const asset = await client.media.upload(fileBlob, { alt: "Team photo" }); // multipart
31
+
32
+ await w.create({ slug: "hello", data: { title: "Hello", body: "..." } });
33
+ // CreateEntryInput = { slug, data, ... } → creates a DRAFT
34
+ // POST /content-types/:type/entries
35
+ // Field value formats (richtext = Tiptap HTML, relation/image = UUIDs, ...):
36
+ // see authoring.md before composing `data`.
37
+
38
+ await w.update("hello", { data: { title: "Hello v2", body: "..." } });
39
+ // UpdateEntryInput = { slug?, data?, ... } — send the FULL data object you want stored
40
+
41
+ // Localized content: pass a `translations` map as an extra key (accepted by the API
42
+ // on both create and update; required+localizable fields MUST live here, not in data):
43
+ await w.update("hello", {
44
+ data: fullBaseData,
45
+ translations: { de: { data: { title: "Hallo" } }, ja: { data: { title: "こんにちは" } } },
46
+ });
47
+
48
+ await w.publish("hello"); // PATCH .../publish
49
+ await w.unpublish("hello"); // PATCH .../unpublish
50
+ await w.archive("hello"); // PATCH .../archive
51
+ await w.schedule("hello", "2026-08-01T09:00:00Z"); // PATCH .../schedule { publish_at } — ISO 8601 UTC
52
+ await w.duplicate("hello"); // POST .../duplicate → new draft copy
53
+ await w.delete("hello"); // DELETE
54
+ await w.bulk([ // POST .../bulk { operations }
55
+ { op: "publish", id: "…" },
56
+ { op: "archive", id: "…" },
57
+ ]);
58
+ ```
59
+
60
+ All idOrSlug parameters accept either the entry ID or its slug.
61
+
62
+ ## Pages
63
+
64
+ ```ts
65
+ // NOTE: pages have NO top-level title — the title lives in seo.title.
66
+ // Block objects need a block_type_id UUID; see authoring.md for the full write shape
67
+ // (seo_translations, block translations, nesting) and the block_type_id caveat.
68
+ await client.pages.create({ slug: "landing", seo: { title: "Landing" }, blocks: [...] });
69
+ await client.pages.update("landing", { seo: { title: "Landing v2" } });
70
+ await client.pages.publish("landing");
71
+ await client.pages.unpublish("landing"); // pages have the FULL lifecycle here
72
+ await client.pages.archive("landing"); // (unlike the MCP tools)
73
+ await client.pages.schedule("landing", "2026-08-01T09:00:00Z");
74
+ await client.pages.delete("landing");
75
+ await client.pages.blocksReorder("landing", ["block-id-2", "block-id-1", "block-id-3"]);
76
+ // PATCH /pages/:slug/blocks/reorder { block_ids } — pass the COMPLETE ordered id list
77
+ ```
78
+
79
+ ## Media
80
+
81
+ ```ts
82
+ import { readFile } from "node:fs/promises";
83
+
84
+ const blob = new Blob([await readFile("./team.jpg")], { type: "image/jpeg" });
85
+ const asset = await client.media.upload(blob, { alt: "Team photo" });
86
+ // multipart POST /media/upload; returns UploadedMediaAsset = { id, ... }
87
+
39
88
  await client.media.delete(asset.id);
40
89
  ```
41
90
 
42
- Every write accepts an optional trailing `{ idempotencyKey: "..." }` pass a stable key
43
- when a retry must not double-create/double-publish.
91
+ Server-side upload validation: max 10 MB; MIME must be `image/*`, `video/*`, or
92
+ `application/pdf` set the Blob `type` explicitly, an untyped Blob is rejected.
93
+
94
+ ## Idempotency & retries (built in)
95
+
96
+ - Every write accepts an optional trailing options argument
97
+ `{ idempotencyKey: "stable-string" }` → sent as the `Idempotency-Key` header. Pass a
98
+ **stable** key when your own retry logic must not double-create or double-publish:
44
99
 
45
- ## Built-ins
100
+ ```ts
101
+ await w.create(input, { idempotencyKey: `import-article-${sourceId}` });
102
+ ```
46
103
 
47
- - **429s are retried automatically** (exponential backoff from 200 ms, up to 3 retries).
48
- Not configurable.
49
- - **Validation errors carry per-field detail** (unlike MCP): `AtlasError.errors` is
50
- `{ field, message }[]` on 400s — surface it to users instead of the generic message.
51
- - Scopes: `content:write` covers create/update/delete/duplicate; `content:publish` covers
52
- publish/unpublish/archive/schedule; `media:write` covers uploads. Missing scope → 403.
104
+ - **429 responses are retried automatically**: exponential backoff from 200 ms, up to 3
105
+ retries. Hardcoded — not configurable, and only for 429 (400/403/500 are not retried).
106
+
107
+ ## Error handling
108
+
109
+ ```ts
110
+ try {
111
+ await w.create({ slug: "x", data: { title: 42 } });
112
+ } catch (e) {
113
+ if (e instanceof AtlasError) {
114
+ e.status; // 400
115
+ e.code; // "VALIDATION_ERROR"
116
+ e.errors; // [{ field: "title", message: "must be a string" }, ...] ← per-field detail,
117
+ } // management client only (MCP loses this) — surface it to users
118
+ }
119
+ ```
120
+
121
+ `ManagementConfigError` (bad config, thrown synchronously) is a different class from
122
+ `AtlasError` (failed requests, rejected promises) — handle both.
123
+
124
+ ## Worked example: idempotent import script
125
+
126
+ ```ts
127
+ for (const row of sourceRows) {
128
+ const slug = slugify(row.title);
129
+ const existing = await readClient.entries("article").get(slug); // delivery client
130
+ const w = client.entries("article");
131
+ if (existing) {
132
+ await w.update(slug, { data: mapRow(row) });
133
+ } else {
134
+ await w.create({ slug, data: mapRow(row) }, { idempotencyKey: `import-${row.id}` });
135
+ }
136
+ await w.publish(slug);
137
+ }
138
+ ```
53
139
 
54
140
  ## Effect-native variant
55
141
 
56
- `@latellu/atlas-sdk/management/effect` exports `makeManagementClient` with the same shape
57
- where every method returns `Effect.Effect<T, AtlasError>`. The plain Promise client above
58
- never bundles `effect`. If you run Effects yourself, note `Effect.runPromise` rejects
59
- with a `FiberFailure` wrapper recover the original `AtlasError` via
60
- `Runtime.isFiberFailure` + `Cause.squash` (the Promise surface already does this for you).
142
+ `@latellu/atlas-sdk/management/effect` exports `makeManagementClient` identical
143
+ surface where every method returns `Effect.Effect<T, AtlasError>`; run with
144
+ `Effect.runPromise`. The plain entrypoints never bundle `effect` as a dependency; only
145
+ this subpath does. Gotcha if you compose Effects yourself: `Effect.runPromise` rejects
146
+ with a `FiberFailure` wrapper, not the raw error recover the original `AtlasError` via
147
+ `Runtime.isFiberFailure` + `Cause.squash` (the Promise surface already does this
148
+ internally).