@latellu/atlas-agent-skills 0.1.0 → 0.2.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.2.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
@@ -38,9 +38,13 @@ Then point your agent at the skill (via `AGENTS.md`, Cursor rules, etc.) and reg
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.2.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 |
@@ -49,7 +79,11 @@ browser-delivered code. Management usage is server-side only (API routes, script
49
79
  Do **not** read references you don't need. If a task spans surfaces (e.g. "generate
50
80
  types, then build the page"), route each sub-task to its own single reference.
51
81
 
52
- ## Extending
82
+ Choosing between MCP and the management SDK for writes: MCP is for operating content
83
+ interactively in an agent session (one-off edits, content entry, audits); the SDK is for
84
+ code that outlives the session (migrations, sync jobs, backends) and for anything MCP
85
+ lacks (bulk, scheduling, page unpublish/archive, media delete, per-field validation
86
+ errors).
53
87
 
54
88
  Deep API detail lives at https://docs.atlas.latellu.com — link there rather than
55
89
  duplicating endpoint documentation into this skill.
@@ -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,98 @@
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 |
13
54
 
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)`.
55
+ ## Recommended write workflow
20
56
 
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)`
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`).
25
71
 
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.
72
+ ## Capability gaps — route elsewhere instead of retrying
29
73
 
30
- ## Recommended workflow for writes
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:
31
76
 
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).
77
+ - Page lifecycle beyond publish: **no `unpublish_page`, `archive_page`,
78
+ `duplicate_page`**.
79
+ - **No** bulk operations, entry/page **scheduling**, or block **reorder** tools.
80
+ - **No `delete_media` / `update_media`** (alt-text edits) — media is upload-and-read
81
+ only through MCP.
82
+ - Pagination is offset-only (`page`/`limit`); there is no cursor option, so listings
83
+ past page 1000 (`PAGE_TOO_DEEP`) are unreachable via MCP.
38
84
 
39
85
  ## Gotchas
40
86
 
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.
87
+ - **Drafts are invisible to live keys** `list_entries(status="draft")` returning
88
+ empty means key scoping, not an empty workspace.
89
+ - **`get_page` block `data` needs `JSON.parse`** per block, or blocks look empty.
90
+ - **`get_entry` ignores `content_type`** slugs shared across types resolve to
91
+ whatever the backend finds by slug.
92
+ - **No client-side 429 retry** — on `Atlas API error: 429`, back off exponentially and
93
+ retry the tool call yourself.
94
+ - Every write sends a fresh auto-generated `Idempotency-Key` per call, so re-issuing a
95
+ failed create as a *new tool call* is a new operation check whether the first call
96
+ actually landed (e.g. `get_entry`) before retrying creates.
97
+ - One MCP server = one workspace (the key's). Multi-workspace work needs one server
98
+ registration per workspace.
@@ -1,82 +1,176 @@
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
5
8
  ```
6
9
 
7
- ## Canonical setup (Next.js thin adapter)
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
+ });
20
+ ```
21
+
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`.
26
+
27
+ ### Canonical Next.js thin adapter
8
28
 
9
- Keep one server-only adapter, e.g. `lib/atlas.ts`:
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`:
52
+
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
60
+
61
+ ```ts
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
+ ```
69
+
70
+ ### Entries
71
+
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 }
79
+
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
83
+ ```
84
+
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.
31
90
 
32
- ## Surface
91
+ ### Pages
33
92
 
34
93
  ```ts
35
- const { items, total, page, pageSize } =
36
- await atlas.entries("news-article").list({ locale: "en", page: 1, limit: 20, sort: "created_at:desc" });
94
+ const list = await atlas.pages.list({ locale: "en", page: 1, limit: 20 });
95
+ // items: AtlasPageSummary[] — lightweight rows { id, slug, status, ... }, NO blocks
37
96
 
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
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
+ ```
102
+
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).
40
108
 
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 }] }
109
+ ### Media & raw
110
+
111
+ ```ts
112
+ const asset = await atlas.media.get(id); // MediaAsset = { id, ...unknown } | null on 404
44
113
 
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
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)
47
118
  ```
48
119
 
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.
120
+ ## Error handling
55
121
 
56
- Errors: non-404 failures throw `AtlasError` (`{ status?, code?, message }`). `get()`
57
- methods return `null` only for 404.
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` field values are raw string IDs/slugs.
159
+ Resolve them explicitly — and in parallel when rendering lists:
160
+
161
+ ```ts
162
+ const article = await atlas.entries("article").get(slug);
163
+ const [cover, author] = await Promise.all([
164
+ atlas.media.get(article.data.cover_image), // image field → media ID
165
+ atlas.entries("author").get(article.data.author), // relation field → entry slug
166
+ ]);
167
+ ```
168
+
169
+ ## Limits to remember
77
170
 
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)`.
171
+ - Delivery keys see **published content only**; draft preview is not yet supported by
172
+ the SDK (preview-token endpoint pending).
173
+ - No `search` or `schema` namespace on the client — schema introspection belongs to the
174
+ CLI (`cli-typegen.md`); anything else goes through `raw.get`.
175
+ - API keys are rate limited; a 429 here is **not** retried automatically (only the
176
+ 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,124 @@ 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
+
36
+ await w.update("hello", { data: { title: "Hello v2", body: "..." } });
37
+ // UpdateEntryInput = { slug?, data?, ... } — send the FULL data object you want stored
38
+
39
+ await w.publish("hello"); // PATCH .../publish
40
+ await w.unpublish("hello"); // PATCH .../unpublish
41
+ await w.archive("hello"); // PATCH .../archive
42
+ await w.schedule("hello", "2026-08-01T09:00:00Z"); // PATCH .../schedule { publish_at } ISO 8601 UTC
43
+ await w.duplicate("hello"); // POST .../duplicate new draft copy
44
+ await w.delete("hello"); // DELETE
45
+ await w.bulk([ // POST .../bulk { operations }
46
+ { op: "publish", id: "" },
47
+ { op: "archive", id: "…" },
48
+ ]);
49
+ ```
50
+
51
+ All idOrSlug parameters accept either the entry ID or its slug.
52
+
53
+ ## Pages
54
+
55
+ ```ts
56
+ await client.pages.create({ slug: "landing", title: "Landing", blocks: [...], seo: {...} });
57
+ await client.pages.update("landing", { title: "Landing v2" });
58
+ await client.pages.publish("landing");
59
+ await client.pages.unpublish("landing"); // pages have the FULL lifecycle here
60
+ await client.pages.archive("landing"); // (unlike the MCP tools)
61
+ await client.pages.schedule("landing", "2026-08-01T09:00:00Z");
62
+ await client.pages.delete("landing");
63
+ await client.pages.blocksReorder("landing", ["block-id-2", "block-id-1", "block-id-3"]);
64
+ // PATCH /pages/:slug/blocks/reorder { block_ids } — pass the COMPLETE ordered id list
65
+ ```
66
+
67
+ ## Media
68
+
69
+ ```ts
70
+ import { readFile } from "node:fs/promises";
71
+
72
+ const blob = new Blob([await readFile("./team.jpg")], { type: "image/jpeg" });
73
+ const asset = await client.media.upload(blob, { alt: "Team photo" });
74
+ // multipart POST /media/upload; returns UploadedMediaAsset = { id, ... }
75
+
39
76
  await client.media.delete(asset.id);
40
77
  ```
41
78
 
42
- Every write accepts an optional trailing `{ idempotencyKey: "..." }` pass a stable key
43
- when a retry must not double-create/double-publish.
79
+ Server-side upload validation: max 10 MB; MIME must be `image/*`, `video/*`, or
80
+ `application/pdf` set the Blob `type` explicitly, an untyped Blob is rejected.
81
+
82
+ ## Idempotency & retries (built in)
83
+
84
+ - Every write accepts an optional trailing options argument
85
+ `{ idempotencyKey: "stable-string" }` → sent as the `Idempotency-Key` header. Pass a
86
+ **stable** key when your own retry logic must not double-create or double-publish:
87
+
88
+ ```ts
89
+ await w.create(input, { idempotencyKey: `import-article-${sourceId}` });
90
+ ```
91
+
92
+ - **429 responses are retried automatically**: exponential backoff from 200 ms, up to 3
93
+ retries. Hardcoded — not configurable, and only for 429 (400/403/500 are not retried).
94
+
95
+ ## Error handling
96
+
97
+ ```ts
98
+ try {
99
+ await w.create({ slug: "x", data: { title: 42 } });
100
+ } catch (e) {
101
+ if (e instanceof AtlasError) {
102
+ e.status; // 400
103
+ e.code; // "VALIDATION_ERROR"
104
+ e.errors; // [{ field: "title", message: "must be a string" }, ...] ← per-field detail,
105
+ } // management client only (MCP loses this) — surface it to users
106
+ }
107
+ ```
108
+
109
+ `ManagementConfigError` (bad config, thrown synchronously) is a different class from
110
+ `AtlasError` (failed requests, rejected promises) — handle both.
44
111
 
45
- ## Built-ins
112
+ ## Worked example: idempotent import script
46
113
 
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.
114
+ ```ts
115
+ for (const row of sourceRows) {
116
+ const slug = slugify(row.title);
117
+ const existing = await readClient.entries("article").get(slug); // delivery client
118
+ const w = client.entries("article");
119
+ if (existing) {
120
+ await w.update(slug, { data: mapRow(row) });
121
+ } else {
122
+ await w.create({ slug, data: mapRow(row) }, { idempotencyKey: `import-${row.id}` });
123
+ }
124
+ await w.publish(slug);
125
+ }
126
+ ```
53
127
 
54
128
  ## Effect-native variant
55
129
 
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).
130
+ `@latellu/atlas-sdk/management/effect` exports `makeManagementClient` identical
131
+ surface where every method returns `Effect.Effect<T, AtlasError>`; run with
132
+ `Effect.runPromise`. The plain entrypoints never bundle `effect` as a dependency; only
133
+ this subpath does. Gotcha if you compose Effects yourself: `Effect.runPromise` rejects
134
+ with a `FiberFailure` wrapper, not the raw error recover the original `AtlasError` via
135
+ `Runtime.isFiberFailure` + `Cause.squash` (the Promise surface already does this
136
+ internally).