Publishing App Content¶
How produced app content reaches Directus and the app. Rails only — the
research/publish copilot skills (Phase 1, naluma-ai-marketplace) automate
these steps. Terms: glossary.md. Lifecycle:
content-lifecycle.md. Editorial standards:
authoring/.
Path¶
repo-first item files (sessions/, coach-conversations/, sounds/) + programme-map.yaml
→ validate the body against the Directus schema for its type (reference-manifest)
→ publish via Directus MCP (POST /items/<collection> + _translations + asset refs)
→ content.* (Supabase)
→ app reads v_published_*
Read — content-automation Neon (research source)¶
- DB: the
naluma-content-automationNeon database. Connection via itsDATABASE_URL(seenaluma-content-automation/.env.example+ the naluma-root secrets inventory). A Neon MCP may also be used. Read-only — never write here. - Tables to mine (verified against
naluma-content-automation/src/db/tables.py): articles.research_dossier(JSONB) — grounded evidence packagesarticleswherecontent_type='research_news'— measured research summaries (≈ insights)source_items— triaged, scored research itemstrusted_sources— authoritative-source allowlistvault_notes/vault_chunks— pgvector-embedded research vault (hybrid search)
Write — Directus MCP (publish target)¶
- Endpoint: dev
https://cmsdev.naluma.space/mcp, prodhttps://cms.naluma.space/mcp(dev hostname confirmed innaluma-directus/CLAUDE.mdandREADME.md; prod not yet live). RequiresMCP_ENABLED=true(set infly.dev.toml [env]) + the in-admin toggle at Settings → AI → Model Context Protocol → Enable MCP Server (owned by naluma-directus). - Role:
Content Editor (MCP)(machine identityinfo+claude@naluma.app, confirmed innaluma-directus/config/users.yaml). CRU oncontent.*exceptlegal_pages/legal_pages_translations(exclusion confirmed innaluma-directus/config/roles.yaml). - Token: personal MCP bearer token — see naluma-root
docs/secrets-inventory.md(~/.naluma-env/directus-mcp.env/DIRECTUS_TOKEN). Never paste the token in this repo. Current storage:~/.zshrc(see theKnown ambiguitiessection of the secrets inventory for the migration TODO). - Connect to Claude Code: add the Directus
/mcpserver with the bearer token (claude mcp addor session MCP config usingDIRECTUS_URL+DIRECTUS_TOKENfromdirectus-mcp.env). - Publish procedure:
POST /items/sessions { slug, locale, status, template_type, category, program_week, tier, content, image_asset_id, audio_asset_id }- translations:
POST /items/sessions_translations { sessions_id, languages_code, title, … } coach_conversations/coach_modules/sounds/sound_albumslikewise- assets: pre-upload to R2 /
directus_files, reference by UUID - set
status='published'to go live (draftotherwise) - Save-time gate: the
coach-content-validatehook (extension atnaluma-directus/extensions/hooks/coach-content-validate/) rejects a save whosesession_card.session_idis not a known SessionKind, whosesession_card.session_slug(when supplied) does not resolve to acontent.sessionsrow, or whosesound_cardref / module ref does not resolve to an existingcontent.*row. Create referenced rows (drafts) first. - Legal pages: NOT publishable via MCP —
legal_pagesandlegal_pages_translationsare intentionally excluded from theContent Editor (MCP)policy (defense-in-depth per OpenSpec changetighten-editorial-security).
Validate before publish¶
Validate the content body against the Directus schema for its type —
naluma-directus/schemas/{session-content,coach-content}/<type>.schema.json, or
the naluma-directus/authoring-docs/reference-manifest.json (confirmed present).
The naluma-app-editor-copilot:publish skill now performs this: it self-validates the body
against the Directus schemas (incl. coach items[]) as an offline pre-flight gate — the
coach-content-validate hook (fixed in naluma-directus#90) is the server-side backstop — uploads
the hero image via REST /files, and upserts the parent + *_translations row(s) at
status: published after a dry-run + explicit confirm. It never deletes (the role is CRU-only);
re-publish is an upsert by directus_id/slug.
Smoke-test (dev)¶
To verify the publish path against dev Directus (cmsdev.naluma.space):
1. Publish a throwaway draft coach_modules row (status: draft, an obvious
mod-smoketest-DELETEME slug) via the Directus MCP; read it back.
2. Attempt a coach_conversations draft whose session_card.session_slug does
NOT resolve to any content.sessions row — confirm the
coach-content-validate hook rejects the save (proves the gate).
3. Delete the throwaway row(s); confirm they are gone.
Result (2026-06-05, dev cmsdev.naluma.space, role Content Editor (MCP)):
- Step 1 — create + read: PASS. Created a draft
coach_modulesrow (mod-smoketest-deleteme,type: pool,status: draft) via the Directus MCP and read it back. Note: theslugfield rejects uppercase — slugs must be lowercase-hyphenated. - Step 2 — gate rejection: FAIL (gate did not fire). A
session_cardwith a validsession_id(breathing) but a non-resolvingsession_slugwas accepted — via both realistic publish paths: (a) a nestedcoach_conversationscreate withtranslations[].items, and (b) a directcoach_conversations_translationscreate. The bad card persisted verbatim in both cases. Thecoach-content-validatehook does not gate the live MCP publish path. Root cause (high confidence, fromnaluma-app/supabase/directus/snapshot.yaml - the hook source): the hook registers
content.coach_conversations.items.create, but (1) the live collection is the unprefixedcoach_conversations, and (2) theitems[]JSONB lives oncoach_conversations_translations, not the parent — so the hook should bind tocoach_conversations_translations.items.create(andcoach_modules_translations.items.create) and readpayload.items/payload.variantsthere. The directus repo's unit/integration tests call the handler directly, so they don't catch the event-name mismatch. Owner:naluma-directus(editorial CMS runtime) — filed as a follow-up; do not fix from this repo. - Step 3 — delete: BLOCKED. The
Content Editor (MCP)identity returns"Delete actions are disabled"— confirms the documented CRU-only role. Throwaway draft rows must be deleted manually in the admin UI (they arestatus: draft, so invisible to the app viav_published_*). - Incidental:
coach_conversations.opening_module_idcarries a stale default UUID that triggers an FK violation on parent create unless overridden — a dev-data quirk, not a rails defect.