@latellu/atlas-agent-skills 0.2.0 → 0.3.1

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.2.0",
3
+ "version": "0.3.1",
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,7 +33,7 @@ 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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@latellu/atlas-agent-skills",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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)",
@@ -75,6 +75,7 @@ is `-1` in cursor mode).
75
75
  | Wanting typed content interfaces for the SDK | CLI type generator | [references/cli-typegen.md](references/cli-typegen.md) |
76
76
  | Writing content from your own code (scripts, migrations, backends) | SDK management client | [references/sdk-management.md](references/sdk-management.md) |
77
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) |
78
79
 
79
80
  Do **not** read references you don't need. If a task spans surfaces (e.g. "generate
80
81
  types, then build the page"), route each sub-task to its own single reference.
@@ -82,8 +83,18 @@ types, then build the page"), route each sub-task to its own single reference.
82
83
  Choosing between MCP and the management SDK for writes: MCP is for operating content
83
84
  interactively in an agent session (one-off edits, content entry, audits); the SDK is for
84
85
  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).
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.
87
98
 
88
99
  Deep API detail lives at https://docs.atlas.latellu.com — link there rather than
89
100
  duplicating endpoint documentation into this skill.
@@ -0,0 +1,133 @@
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
+ - **A page without blocks cannot be published** — publish returns an error requiring at
117
+ least one block. Combined with `block_type_id` being undiscoverable, an API-only
118
+ workflow can create and edit pages but **never publish a new one**; publishing needs
119
+ someone to add a block in the dashboard first (or block-type UUIDs supplied to you).
120
+ - **The write plane has no reads**, and write responses don't echo `seo_translations` —
121
+ you cannot fully verify a translation write without a live key for the same workspace.
122
+ Trick: `PUT /pages/:slug` with body `{}` is a safe no-op that returns the page's
123
+ current base state (status + seo) — the only "read" available on the management plane.
124
+
125
+ ## Practical write checklist
126
+
127
+ 1. `get_content_type` / schema → exact field names, types, required, localizable, select options.
128
+ 2. Uploads first → collect media UUIDs.
129
+ 3. Compose `data` per the table above; localizable required fields go in `translations`.
130
+ 4. Create (draft) → read back → verify → publish.
131
+ 5. On `VALIDATION_ERROR` via MCP (no field detail): re-check step 1-3 against this file
132
+ before retrying — the usual culprits are slug-instead-of-UUID in relation/image,
133
+ markdown in richtext, and required-localizable fields sitting in `data`.
@@ -74,6 +74,10 @@ errors).
74
74
  These exist in the backend/SDK but have **no MCP tool**; say so and point to the
75
75
  dashboard or the management SDK (`sdk-management.md`) rather than improvising:
76
76
 
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`).
77
81
  - Page lifecycle beyond publish: **no `unpublish_page`, `archive_page`,
78
82
  `duplicate_page`**.
79
83
  - **No** bulk operations, entry/page **scheduling**, or block **reorder** tools.
@@ -81,6 +85,14 @@ dashboard or the management SDK (`sdk-management.md`) rather than improvising:
81
85
  only through MCP.
82
86
  - Pagination is offset-only (`page`/`limit`); there is no cursor option, so listings
83
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`.
92
+
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.
84
96
 
85
97
  ## Gotchas
86
98
 
@@ -96,3 +108,7 @@ dashboard or the management SDK (`sdk-management.md`) rather than improvising:
96
108
  actually landed (e.g. `get_entry`) before retrying creates.
97
109
  - One MCP server = one workspace (the key's). Multi-workspace work needs one server
98
110
  registration per workspace.
111
+ - **The server never checks that the live and mgmt keys belong to the same workspace.**
112
+ A mismatched pair is maximally confusing: schema and reads come from workspace A while
113
+ every write 404s ("content type not found") against workspace B. If reads succeed but
114
+ writes 404 on types you just listed, suspect mismatched keys before anything else.
@@ -155,17 +155,26 @@ export function BlockRenderer({ blocks }: { blocks: AtlasBlock[] }) {
155
155
 
156
156
  ## Resolving reference fields
157
157
 
158
- `image` / `relation` / `content_type_reference` field values are raw string IDs/slugs.
159
- Resolve them explicitly and in parallel when rendering lists:
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:
160
161
 
161
162
  ```ts
162
163
  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
- ]);
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);
167
173
  ```
168
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
+
169
178
  ## Limits to remember
170
179
 
171
180
  - Delivery keys see **published content only**; draft preview is not yet supported by
@@ -32,10 +32,19 @@ const w = client.entries("news-article");
32
32
  await w.create({ slug: "hello", data: { title: "Hello", body: "..." } });
33
33
  // CreateEntryInput = { slug, data, ... } → creates a DRAFT
34
34
  // POST /content-types/:type/entries
35
+ // Field value formats (richtext = Tiptap HTML, relation/image = UUIDs, ...):
36
+ // see authoring.md before composing `data`.
35
37
 
36
38
  await w.update("hello", { data: { title: "Hello v2", body: "..." } });
37
39
  // UpdateEntryInput = { slug?, data?, ... } — send the FULL data object you want stored
38
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
+
39
48
  await w.publish("hello"); // PATCH .../publish
40
49
  await w.unpublish("hello"); // PATCH .../unpublish
41
50
  await w.archive("hello"); // PATCH .../archive
@@ -53,9 +62,12 @@ All idOrSlug parameters accept either the entry ID or its slug.
53
62
  ## Pages
54
63
 
55
64
  ```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");
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"); // fails unless the page has ≥1 block — see authoring.md
59
71
  await client.pages.unpublish("landing"); // pages have the FULL lifecycle here
60
72
  await client.pages.archive("landing"); // (unlike the MCP tools)
61
73
  await client.pages.schedule("landing", "2026-08-01T09:00:00Z");