@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -3
- package/package.json +1 -1
- package/skills/atlas-cms/SKILL.md +63 -18
- package/skills/atlas-cms/references/authoring.md +125 -0
- package/skills/atlas-cms/references/cli-typegen.md +66 -24
- package/skills/atlas-cms/references/mcp.md +97 -50
- package/skills/atlas-cms/references/sdk-delivery.md +144 -41
- package/skills/atlas-cms/references/sdk-management.md +123 -35
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "atlas",
|
|
3
|
-
"version": "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`
|
|
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.
|
|
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
|
-
##
|
|
11
|
+
## Content model
|
|
12
12
|
|
|
13
|
-
**
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
18
|
-
and `atlas.types.ts` is appended.
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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");
|
|
54
|
+
const article = await atlas.entries("news-article").get("hello");
|
|
55
|
+
article?.data.category; // "News" | "Event" — fully typed
|
|
32
56
|
```
|
|
33
57
|
|
|
34
|
-
##
|
|
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
|
-
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
##
|
|
86
|
+
## Troubleshooting
|
|
48
87
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
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
|
-
|
|
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
|
-
**
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
-
|
|
42
|
-
|
|
43
|
-
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
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" });
|
|
38
|
+
fetch(input, { ...init, cache: "no-store" });
|
|
20
39
|
|
|
21
40
|
export const atlas = createClient<AtlasContentTypes>({
|
|
22
|
-
url: process.env.ATLAS_BASE_URL ?? "",
|
|
23
|
-
apiKey: process.env.ATLAS_API_KEY ?? "",
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
// entry: { id, slug, status, published_at, data } — data is already parsed to an object
|
|
70
|
+
### Entries
|
|
40
71
|
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
- `
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
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":
|
|
69
|
-
case "faq":
|
|
70
|
-
default:
|
|
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
|
-
##
|
|
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
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
##
|
|
27
|
+
## Entries
|
|
20
28
|
|
|
21
29
|
```ts
|
|
22
30
|
const w = client.entries("news-article");
|
|
23
|
-
|
|
24
|
-
await w.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
await w.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
100
|
+
```ts
|
|
101
|
+
await w.create(input, { idempotencyKey: `import-article-${sourceId}` });
|
|
102
|
+
```
|
|
46
103
|
|
|
47
|
-
- **
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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`
|
|
57
|
-
where every method returns `Effect.Effect<T, AtlasError
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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).
|