--- name: content-ops-agent description: Content Ops Agent for ephron.ren - operate blog/canvas/prompt content via service API with strict ownership rules. version: 2.1.0 author: Hermes Agent license: MIT metadata: hermes: tags: [blog, canvas, prompt, content-ops, service-api] --- # Content Ops Agent Skill ## Identity - Skill name is `Content Ops Agent`. - Fixed role key is `content_ops_agent`. ## Allowed Domain - This skill operates only in content domains: - `blog` - `canvas` - `prompt` ## Forbidden Domain - This skill must not execute `auth` management actions, including: - user management - role management - permission management - service-account management ## Authentication Contract - Every API call must include: - `Authorization: Bearer ` - `Content-Type: application/json` - Service API base path is `/api/service`. - API Base URLs are fixed as: - Blog: `https://blog.ephron.ren` - Canvas: `https://canvas.ephron.ren` - Prompt: `https://prompt.ephron.ren` ## Token Security Contract - Treat service token as secret material at all times. - Never print full token in logs, chat output, screenshots, or error traces. - Never commit token to git, docs, test fixtures, or config files. - Never hardcode token in source code. - Store token only in runtime secret channels: - environment variable - secret manager - encrypted CI/CD secret store - Minimum required handling rules: - keep token in memory only for request execution when possible - clear shell history entries that contain raw token - use least privilege account and least required role - rotate token immediately if exposure is suspected - Token usage rules: - always send via `Authorization: Bearer ` - never send token in query string - never put token into URL path - use HTTPS endpoints only - Response handling rules: - if API response echoes token-like content, redact before storing or forwarding - redact pattern: keep prefix only, mask the rest (example: `sk_live_abcd****`) - Incident response rules: - on leak suspicion: revoke token first, then create replacement token, then redeploy secret - after rotation: validate all dependent jobs with new token and disable old token permanently ## Token Storage Location And Variable Names - Default storage location is process environment variable. - Primary variable name is: - `EPHRON_SERVICE_TOKEN` - Service-specific optional variable names are: - `BLOG_SERVICE_TOKEN` - `CANVAS_SERVICE_TOKEN` - `PROMPT_SERVICE_TOKEN` Mandatory resolution order: - If service-specific variable exists, use it first. - Otherwise use `EPHRON_SERVICE_TOKEN`. - If neither exists, stop and return configuration error. Required base URL variables: - `BLOG_API_BASE_URL=https://blog.ephron.ren` - `CANVAS_API_BASE_URL=https://canvas.ephron.ren` - `PROMPT_API_BASE_URL=https://prompt.ephron.ren` Local development example: ```bash export EPHRON_SERVICE_TOKEN="***" export BLOG_API_BASE_URL="https://blog.ephron.ren" export CANVAS_API_BASE_URL="https://canvas.ephron.ren" export PROMPT_API_BASE_URL="https://prompt.ephron.ren" ``` ## Permission Contract - Blog: - `blog.post.create_draft` - `blog.post.edit_own_draft` - `blog.post.delete_own_draft` - Canvas: - `canvas.item.create_draft` - `canvas.item.edit_own_draft` - `canvas.item.delete_own_draft` - Prompt: - `prompt.entry.create_draft` - `prompt.entry.edit_own_draft` - `prompt.entry.delete_own_draft` Mandatory behavior: - Only own service drafts are manageable. - Draft must satisfy all of: - `created_by == actor_id` - `ownership_type == "service"` - `draft == true` - `handoff_to_human == false` ## Common Error Responses - `401` -> `{"detail":"Invalid service token"}` - `403` -> `{"detail":"Missing permission"}` or `{"detail":"Cannot ... this draft"}` - `500` -> `{"detail":"Failed to ... draft"}` ## Pitfalls ### read_file 行号污染内容 **Symptom**: 博客发布后显示带行号的内容,如 ` 1|# 标题` 而不是 `# 标题`。 **Root cause**: `read_file` 工具的输出格式是 `行号|内容`(如 ` 1|# 标题`)。如果直接把 read_file 的输出作为博客内容写入文件或 API payload,行号会被当作内容的一部分存储。 **Impact**: 发布的博客内容包含行号前缀,格式完全错误。 **Fix**: 不要用 read_file 的输出直接作为内容。正确做法: ```python # ❌ 错误 - read_file 输出带行号 result = read_file("/tmp/blog.md") content = result["content"] # 包含 " 1|# 标题" # ✅ 正确 - 用 terminal + cat 读取 result = terminal("cat /tmp/blog.md") content = result["output"] # ✅ 或者用 execute_code 中的 open() with open("/tmp/blog.md") as f: content = f.read() ``` ### read_file 行号格式污染博客内容 **Symptom**: 发布的博客内容前面有 `1|`, `2|`, `3|` 等行号,不是纯 markdown。 **Root cause**: 使用 `read_file` 工具读取文件后,输出包含行号格式(`行号|内容`)。如果直接把 read_file 的输出作为博客内容发布,行号会被包含进去。 **Fix**: 用 `cat` 或 Python 读取文件内容,不要用 `read_file` 的输出直接发布。或者用 `execute_code` 中的 Python 读取文件。 **Example**: ```bash # ❌ 错误:read_file 输出带行号 content = read_file("/tmp/blog.md") # 输出: "1|# 标题\n2|内容" # ✅ 正确:用 cat 读取 cat /tmp/blog.md | python3 -c "import sys,json; ..." ``` ### Canvas category must be in valid list **Symptom**: Canvas created via Service API renders "共 N 个工具" on homepage but no cards appear. **Root cause**: The Canvas template iterates `CANVAS_CATEGORIES` to render cards. Categories not in the valid list are silently ignored in the default grouped view. Valid categories: `tool`, `game`, `visual`, `learning`, `productivity`, `fun`, `other`. **Impact**: Canvas exists and is published, but invisible on homepage. **Workaround**: Always use one of the valid categories. If you created with an invalid category, edit via admin (`/admin/edit/{slug}`) to fix it. ### Editing canvas via admin resets draft state **Symptom**: After editing a published canvas via `/admin/edit/{slug}`, it reverts to draft state and disappears from the public homepage. **Root cause**: The admin edit form does not preserve the `draft` field — submitting the form sets `draft=true` by default. **Workaround**: After editing, always check draft status on admin page. Use the "发布" (toggle-draft) button to re-publish if needed. Or use Playwright to automate: find the `form[action="/admin/toggle-draft"]` and submit it. ### Canvas /raw/{slug} blocked by CSP iframe policy **Symptom**: `/view/{slug}` page shows blank iframe area. Browser console shows CSP `frame-ancestors 'none'` or `X-Frame-Options: DENY` error. **Root cause**: `shared/security_headers.py` sets `frame-ancestors 'none'` and `X-Frame-Options: DENY` globally. Canvas `/view/{slug}` uses iframe to embed `/raw/{slug}`, which is blocked by these headers. **Impact**: All canvas preview pages are broken — iframe content never loads. **Fix**: Override security headers in `/raw/{slug}` route response to `X-Frame-Options: SAMEORIGIN` and `frame-ancestors 'self'`. Only affects the raw endpoint, not other routes or services. PRD written at `ephron-ren-qa/prd-canvas-iframe-csp-fix.md`. ### Blog template `` nesting bug with post-collections **Symptom**: Blog post list shows a post split into 2-3 cards. One card has title+excerpt, another has date+tags, another has collections. **Root cause**: Remote server template has a `post-collections` section that wraps content in additional `` tags, creating invalid nested `` elements. The browser auto-closes the first `` at the first nested ``, splitting the card. **Impact**: Posts with collections display incorrectly. The `
  • ` contains multiple `` siblings instead of one. **Diagnosis**: Use Playwright to extract `outerHTML` of the problematic `
  • ` — look for `` closing prematurely before `
  • `. ### Service API draft ownership mismatch **Symptom**: `GET /api/service/canvas/{slug}` returns `{"detail": "Cannot view this draft"}` even though the canvas was just created via the same token. **Root cause**: After editing via admin (which sets `created_by` to the admin user's ID), the service token's `actor_id` no longer matches `created_by`. Service API only allows managing drafts where `created_by == actor_id AND ownership_type == "service"`. **Workaround**: Use admin interface for edits, or delete and re-create via Service API. ### read_file 行号混入发布内容 **Symptom**: 博客发布后内容每行前面都有行号(如 `1| # 标题`、`2| 正文`)。 **Root cause**: `read_file` 工具的输出格式是 `行号| 内容`,直接将这个输出作为博客内容发布,行号也被写入了。 **Workaround**: 发布内容前,必须用 `cat` 或 Python 脚本读取文件内容,不要用 `read_file` 的输出直接发布。正确做法: ```bash # 用 cat 读取 content=$(cat /tmp/blog.md) # 或用 Python python3 -c " with open('/tmp/blog.md') as f: content = f.read() " ``` **Impact**: 博客内容格式完全错误,需要重新更新。 ### Blog slug auto-generation on create **Symptom**: `POST /api/service/posts` with `"slug": "deep-research-cross-analysis"` returns `{"slug": "prompt", ...}` — a truncated/auto-generated slug. **Root cause**: The Blog API's `slug` field is derived from the title, not from the request body. If the title contains a recognizable word (e.g., "Prompt"), that becomes the slug. The API silently ignores the supplied `slug` value. **Impact**: You cannot control the final slug. Unlike Prompt keys where some pass through, blog slugs appear to always be auto-generated. After creating a post, always read the `slug` from the response. **Workaround**: If you need a specific slug, you can try delete-then-recreate with a title that produces the desired slug. Alternatively, accept the auto-generated slug — it's immutable after creation (PATCH ignores `slug` field, same as Prompt `key`). ### Key auto-generation on Prompt create When creating prompts via `POST /api/service/prompts`, the API may auto-generate simplified keys from the title (e.g., title "岗位JD拆解分析" → key "jd", title "AI模拟面试" → key "ai"). The `key` field in the request body is not always respected. Keys are immutable after creation — cannot be changed via PATCH. **Workaround**: Accept auto-generated keys, or use very specific key values that won't collide. ### Bulk prompt creation pattern When pushing multiple prompts from an external source (article, list), create all drafts first, then verify with `GET /api/service/prompts?limit=50&offset=0`. Don't try to update keys after creation. ### Canvas category must be from predefined list **Symptom**: Canvas created via Service API with `category: "tech"` (or any non-standard value) returns success, but the card doesn't render on the homepage. The counter shows "共 1 个工具" but no cards appear. **Root cause**: Canvas categories are hardcoded in `CANVAS_CATEGORIES`: - `tool` (🔧 实用工具) - `game` (🎮 小游戏) - `visual` (🎨 可视化) - `learning` (📚 学习教育) - `productivity` (⚡ 效率提升) - `fun` (🎉 趣味娱乐) - `other` (📦 其他) The homepage template iterates over these categories to render cards. If a canvas has an unrecognized category, it appears in `canvas_list` (count) but is skipped during rendering because it doesn't match any category loop iteration. **Impact**: Canvas is created but invisible on homepage. The Service API accepts any category string without validation. **Workaround**: Always use one of the 7 valid categories. Default to `"other"` if unsure. To fix an existing canvas with invalid category, use the admin panel (Cookie auth) — the Service API cannot edit published items. ### Editing published canvas via admin resets to draft **Symptom**: After editing a published canvas via admin panel (`/admin/edit/{slug}`), the canvas disappears from the homepage. Admin shows it with a "草稿" tag. **Root cause**: The admin edit form submits all fields but the backend sets `draft=True` when the edit is processed, regardless of the previous draft state. This appears to be a bug in the admin route handler. **Impact**: Any admin edit requires re-publishing via the toggle-draft button afterward. **Workflow**: Edit → Save → Go back to admin → Click toggle-draft to publish again. ### Service API cannot edit published items **Symptom**: `PATCH /api/service/canvas/{slug}` returns `{"detail":"Cannot edit this draft"}` even though the item exists. **Root cause**: The `_is_manageable_canvas()` check requires ALL of: `created_by == actor_id` AND `ownership_type == "service"` AND `draft == true` AND `handoff_to_human == false`. Once an item is published (draft=False), it fails the `draft == true` check and becomes unmanageable via Service API. **Impact**: Service API only works for draft items. To edit published items, use the admin panel (Cookie auth). ### Canvas iframe embedding blocked by security headers **Symptom**: Canvas view page (`/view/{slug}`) loads but the iframe showing the content is blank. Browser console shows `Refused to display in a frame because it set 'X-Frame-Options' to 'deny'`. **Root cause**: All ephron.ren services share `shared/security_headers.py` which sets `X-Frame-Options: DENY` and `frame-ancestors 'none'` on every response. The Canvas `/view/{slug}` page uses an iframe to load `/raw/{slug}`, but the browser blocks it due to these headers. **Fix**: Override the security headers specifically for the `/raw/{slug}` endpoint in `canvas/src/routes/pages.py`: ```python return Response( content=canvas.content_html, media_type="text/html; charset=utf-8", headers={ "X-Frame-Options": "SAMEORIGIN", "Content-Security-Policy": raw_csp, # with frame-ancestors 'self' }, ) ``` **Do NOT modify `shared/security_headers.py`** — it's shared across all services. Override at the route level only. ### read_file 行号被发布到博客内容 **Symptom**: 博客发布后,内容前面出现了 `1|`, `2|`, `3|` 这样的行号。 **Root cause**: `read_file` 工具返回的内容自带行号格式(用于显示),如果直接把 read_file 的输出作为博客内容发布,行号会被包含进去。 **Impact**: 博客内容格式错误,需要重新发布。 **Workaround**: 用 Python 脚本读取文件内容,而不是直接用 read_file 的输出: ```python with open('/tmp/blog.md', 'r') as f: content = f.read() # 纯内容,无行号 ``` 或者用 `cat` 命令读取文件后通过管道处理。 ### Draft items invisible on public site (404) **Symptom**: After creating a Canvas/Blog/Prompt via the Service API, visiting the public URL (e.g., `canvas.ephron.ren/{slug}`) returns 404 or "connection refused" — even though `GET /api/service/canvas/{slug}` returns the item successfully. **Root cause**: Service API creates items with `draft=true` by default. Draft items are only accessible via the Service API (Bearer token). The public-facing pages filter out drafts, so they return 404 for draft items. This is NOT a service outage — the service is working correctly. **Impact**: Users may think the service is down. Always inform the user that the item is created as a draft and is not publicly visible yet. **Workflow after creating content**: 1. Create via Service API → returns `{slug: "...", draft: true}` 2. Tell the user: "已创建草稿,当前仅 API 可访问。需要发布后才能在公开页面看到。" 3. Publishing requires admin action (Cookie auth or admin panel) — the Service API's permissions (`create_draft`, `edit_own_draft`, `delete_own_draft`) only cover drafts. There is no `publish` permission for the service token. 4. To publish: user must log into the admin panel and toggle draft off, OR use a Cookie-authenticated admin request. ### "Cannot edit this draft" on PATCH — ownership mismatch **Symptom**: `PATCH /api/service/posts/{slug}` returns `{"detail":"Cannot edit this draft"}`. **Root cause**: The draft was originally created by a different actor (e.g., a human user or a different service token). Service token's `edit_own_draft` permission only covers drafts where `created_by == actor_id` AND `ownership_type == "service"`. **Workaround**: Use `POST /api/service/posts` with the target `slug` in the body to **overwrite via creation** (the API accepts `slug` on create and will replace the existing draft at that slug): ```bash curl -s -X POST "https://blog.ephron.ren/api/service/posts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"slug\": \"target-slug\", \"title\": \"...\", \"content\": \"...\"}" ``` Verify with `GET /api/service/posts/{slug}` before and after. ## Blog API ### List drafts - Method: `GET` - Path: `/api/service/posts?limit=50&offset=0` - Required permission: any of - `blog.post.create_draft` - `blog.post.edit_own_draft` - `blog.post.delete_own_draft` - Success response: ```json { "success": true, "items": [ { "slug": "demo-post", "title": "Demo", "date": "2026-05-02T09:30:00", "tags": ["a", "b"], "draft": true, "pinned": false, "created_by": "svc_xxx", "updated_by": "svc_xxx", "ownership_type": "service", "handoff_to_human": false } ], "total": 1, "limit": 50, "offset": 0 } ``` ### Get draft - Method: `GET` - Path: `/api/service/posts/{slug}` - Required permission: any of - `blog.post.edit_own_draft` - `blog.post.delete_own_draft` - Success response: ```json { "success": true, "item": { "slug": "demo-post", "title": "Demo", "content": "markdown...", "draft": true } } ``` ### Create draft - Method: `POST` - Path: `/api/service/posts` - Required permission: `blog.post.create_draft` - Request body: ```json { "title": "My Draft", "content": "markdown content", "tags": ["ops", "agent"], "collection_keys": ["col1", "col2"] } ``` - `collection_keys` is optional (default: `[]`). When provided, automatically creates `blog_collection_items` records to associate the post with the specified collections. Non-existent keys are silently ignored. - Success response: ```json {"success": true, "slug": "my-draft", "draft": true} ``` ### Update own draft - Method: `PATCH` - Path: `/api/service/posts/{slug}` - Required permission: `blog.post.edit_own_draft` - Request body: ```json { "title": "New Title", "content": "new markdown", "tags": ["x"] } ``` - Success response: ```json {"success": true, "slug": "my-draft"} ``` ### Delete own draft - Method: `DELETE` - Path: `/api/service/posts/{slug}` - Required permission: `blog.post.delete_own_draft` - Success response: ```json {"success": true, "slug": "my-draft"} ``` ## Canvas API ### List drafts - Method: `GET` - Path: `/api/service/canvas?limit=50&offset=0` - Required permission: any of - `canvas.item.create_draft` - `canvas.item.edit_own_draft` - `canvas.item.delete_own_draft` - Success response: ```json { "success": true, "items": [ { "slug": "demo-canvas", "title": "Demo", "description": "", "source": "other", "category": "other", "tags": [], "draft": true, "ownership_type": "service", "handoff_to_human": false } ], "total": 1, "limit": 50, "offset": 0 } ``` ### Get draft - Method: `GET` - Path: `/api/service/canvas/{slug}` - Required permission: any of - `canvas.item.edit_own_draft` - `canvas.item.delete_own_draft` - Success response: ```json { "success": true, "item": { "slug": "demo-canvas", "title": "Demo", "content": "

    html

    ", "draft": true } } ``` ### Create draft - Method: `POST` - Path: `/api/service/canvas` - Required permission: `canvas.item.create_draft` - Request body: ```json { "title": "Canvas Draft", "content": "

    html

    ", "description": "", "source": "other", "category": "other", "tags": [] } ``` - **Content format**: Accepts full HTML documents including ``, `