Twenty tools cover every blog operation an agent might want — discovery, taxonomy (categories/tags), content writes, lifecycle management, and analytics (stubbed for now).
All endpoints follow the pattern POST https://mcp.vlozi.app/tools/blog.<name> with Authorization: Bearer ls_xxx. All responses use this envelope:
{ "data": <payload>, "error": null } // success
{ "data": null, "error": "..." } // failure (with HTTP 4xx/5xx)Discovery
blog.list_posts
List posts in your workspace.
Scope: blog:posts.read
Input:
| Field | Type | Notes |
|---|---|---|
status |
"draft" | "published" | "scheduled" |
Filter by state |
page |
number |
1-based, default 1 |
limit |
number |
Default 20, max 100 |
sort |
"createdAt" | "publishedAt" | "title" |
Default createdAt |
order |
"asc" | "desc" |
Default desc |
Response: { posts: Post[], pagination: { page, limit, total, totalPages, hasMore } }
Each Post includes id, title, slug, status, excerpt, categoryId, featuredImageUrl, seoTitle, seoDescription, scheduledFor, publishedAt, createdAt, updatedAt, tags[].
curl -X POST https://mcp.vlozi.app/tools/blog.list_posts \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-H "content-type: application/json" \
-d '{"status": "published", "limit": 10, "sort": "publishedAt"}'blog.get_post
Get one post by id OR slug — pass exactly one. Returns the full TipTap content body plus hydrated tags and category.
Scope: blog:posts.read
Input: { id?: string, slug?: string }
Response: { post: FullPost } — FullPost includes content (TipTap doc), tags[], and category.
# By id
curl -X POST https://mcp.vlozi.app/tools/blog.get_post \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"id": "post_xxx"}'
# By slug
curl -X POST https://mcp.vlozi.app/tools/blog.get_post \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"slug": "hello-world"}'blog.search_posts
Substring search across title, slug, and excerpt (case-insensitive).
Scope: blog:posts.read
Input: { query: string, limit?: number } (limit default 20, max 50)
Response: { posts: PostSummary[], query: string }
TIP
For semantic search across post bodies, use brain.query against ingested content instead.
curl -X POST https://mcp.vlozi.app/tools/blog.search_posts \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"query": "ai", "limit": 5}'blog.list_categories
List all categories with how many posts use each.
Scope: blog:posts.read
Input: { limit?: number } (default 100, max 500)
Response: { categories: { id, name, slug, postCount }[] }
Call this before passing categoryId to create_draft or update_post so your agent can pick a real one.
blog.list_tags
List all tags with how many posts use each.
Scope: blog:posts.read
Input: { limit?: number } (default 100, max 500)
Response: { tags: { id, name, slug, postCount }[] }
NOTE
When creating or updating posts, pass tag names (not IDs) — the service upserts new tags automatically. Use list_tags to see what already exists if you want to reuse them.
Taxonomy
CRUD for categories and tags, at parity with the admin dashboard. Prefer passing tag names directly to create_draft/update_post (which upsert automatically) — use create_tag only to pre-create a tag without attaching it to a post yet.
blog.create_category
Create a new category. Call list_categories first — only create one when no suitable category exists.
Scope: blog:posts.create
Input: { name: string } (1–100 chars)
Response: { category: { id, name, slug } }
NOTE
Returns 409 if a category with the same name (or derived slug) already exists in the workspace.
curl -X POST https://mcp.vlozi.app/tools/blog.create_category \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"name": "Product Updates"}'blog.update_category
Rename a category. The slug is re-derived from the new name.
Scope: blog:posts.update
Input: { id: string, name: string } (1–100 chars)
Response: { category: { id, name, slug } }
Returns 404 if the category isn't in this workspace, or 409 if another category already uses the resulting name/slug.
blog.delete_category
Delete a category. Posts in that category are not deleted — their categoryId is cleared, not the post itself.
Scope: blog:posts.delete
Input: { id: string }
Response: { id: string, deleted: true }
CAUTION
Irreversible. Agents should confirm with the user before calling. Returns 404 if the category isn't in this workspace.
blog.create_tag
Create a new tag. The name must be unique in the workspace.
Scope: blog:posts.create
Input: { name: string } (1–50 chars)
Response: { tag: { id, name, slug } }
NOTE
Returns 409 on a duplicate name/slug.
blog.update_tag
Rename a tag. The slug is re-derived, and every post using the tag reflects the new name.
Scope: blog:posts.update
Input: { id: string, name: string } (1–50 chars)
Response: { tag: { id, name, slug } }
Returns 404 if the tag isn't in this workspace, or 409 if another tag already uses the new name/slug.
blog.delete_tag
Delete a tag. It's removed from every post that used it — the posts themselves are kept.
Scope: blog:posts.delete
Input: { id: string }
Response: { id: string, deleted: true }
CAUTION
Irreversible. Agents should confirm with the user before calling. Returns 404 if the tag isn't in this workspace.
Content writes
blog.create_draft
Create a new draft.
Scope: blog:posts.create
Input:
| Field | Type | Notes |
|---|---|---|
title |
string (required) |
Max 255 chars |
content |
string | object |
Plain text (auto-wrapped as paragraph) or full TipTap doc |
slug |
string |
Auto-generated from title if omitted; collision-safe |
excerpt |
string |
Max 1000 chars |
categoryId |
string |
Must exist in your workspace |
tags |
string[] |
Tag names (not IDs); new tags auto-created |
seoTitle |
string |
Defaults to title |
seoDescription |
string |
Max 500 chars |
featuredImageUrl |
string |
Response: { post: FullPost } with status: "draft".
# Plain text
curl -X POST https://mcp.vlozi.app/tools/blog.create_draft \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-H "content-type: application/json" \
-d '{
"title": "My first agent-written post",
"content": "Hello, world. I am an AI.",
"tags": ["ai-generated", "intro"],
"excerpt": "Drafted by an agent."
}'
# Rich content (TipTap doc)
curl -X POST https://mcp.vlozi.app/tools/blog.create_draft \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-H "content-type: application/json" \
-d '{
"title": "Rich content",
"content": {
"type": "doc",
"content": [
{ "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Intro" }] },
{ "type": "paragraph", "content": [{ "type": "text", "text": "Body paragraph." }] }
]
}
}'blog.generate_draft
AI-write a full draft from a title + optional brief, then save it as a draft — the model writes the body, you review/edit and publish separately. A good flow action: turn an idea (or a form submission) into a ready-to-edit draft.
Scope: blog:posts.create
Input:
| Field | Type | Notes |
|---|---|---|
title |
string (required) |
Max 255 chars — also steers the content |
brief |
string |
Optional key points / angle / audience for the model to cover |
tone |
string |
Optional, e.g. "friendly", "authoritative", "playful" |
length |
"short" | "medium" | "long" |
~300w / ~600w / ~1000w. Default medium |
categoryId |
string |
Must exist in your workspace — validated before charging |
tags |
string[] |
Tag names (not IDs); new tags auto-created |
Response: { post: FullPost, creditsCharged: 30, model: string }
IMPORTANT
Charges a flat 30-credit AI fee up front, refunded automatically if generation fails. Returns 503 if no AI model key is configured, 402 if you're out of credits, 403 if the plan is inactive, or 502 (with the charge refunded) if generation itself fails.
curl -X POST https://mcp.vlozi.app/tools/blog.generate_draft \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-H "content-type: application/json" \
-d '{
"title": "Why we rebuilt our onboarding flow",
"brief": "Cover the 3 biggest drop-off points we fixed and the metrics that moved.",
"tone": "friendly",
"length": "medium"
}'blog.update_post
Update any subset of fields. Omit a field to leave it unchanged.
Scope: blog:posts.update
Input: Same shape as create_draft plus required id. Special semantics:
| Behavior | How |
|---|---|
| Replace tag set | Pass tags: ["a", "b"] — REPLACES existing tags |
| Clear all tags | Pass tags: [] |
| Clear category | Pass categoryId: null |
| Keep existing tags | Omit tags from request |
WARNING
tags is a replace, not a merge. To preserve existing tags while adding new ones, call get_post first, then send the merged list.
Response: { post: FullPost }
curl -X POST https://mcp.vlozi.app/tools/blog.update_post \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-H "content-type: application/json" \
-d '{
"id": "post_xxx",
"title": "Updated title",
"excerpt": "Refreshed by an agent."
}'blog.delete_post
Soft-delete a post — sets a tombstone and cancels any pending publish schedule. Recoverable via restore_post. The category is untouched; tags stay attached (they're only cascade-cleared if the post is later hard-deleted at the database level, which no MCP tool does).
Scope: blog:posts.delete
Input: { id: string }
Response: { id: string, deleted: true }
NOTE
Not permanent — see restore_post below. Agents should still confirm with the user before calling, since the post disappears from every listing immediately.
blog.restore_post
Restore a soft-deleted post — reverses delete_post.
Scope: blog:posts.delete
Input: { id: string }
Response: { id: string, restored: true }
NOTE
Returns 404 if the post doesn't exist or isn't currently deleted. A post deleted while "scheduled" restores as "draft", not "scheduled" — its publish schedule was already cancelled at delete time. If a live post has taken the same slug in the meantime, the restored post's slug gets a unique suffix.
Lifecycle
blog.publish_post
Publish a draft immediately, or schedule it for a future ISO 8601 timestamp.
Scope: blog:posts.publish
Input: { id: string, scheduledFor?: string }
scheduledFor value |
Result |
|---|---|
| Omitted | Publishes immediately, sets publishedAt: now, status → "published" |
| Future ISO 8601 | Schedules, sets scheduledFor, status → "scheduled" |
| Past or invalid | 400 invalid_input — rejected |
Response: { post: { id, status, publishedAt, scheduledFor } }
# Publish now
curl -X POST https://mcp.vlozi.app/tools/blog.publish_post \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"id": "post_xxx"}'
# Schedule for tomorrow 9am UTC
curl -X POST https://mcp.vlozi.app/tools/blog.publish_post \
-H "Authorization: Bearer $VLOZI_API_KEY" \
-d '{"id": "post_xxx", "scheduledFor": "2026-05-15T09:00:00Z"}'blog.unpublish_post
Move a published or scheduled post back to draft. Public URLs stop serving it.
Scope: blog:posts.publish
Input: { id: string }
Response: { post: { id, status } }
blog.unschedule_post
Clear a post's scheduled publish time, returning it to draft. Does not affect already-published posts — calling this on a live post returns 409 to prevent accidental takedowns.
Scope: blog:posts.publish
Input: { id: string }
Response on scheduled post: { post: { id, status: "draft" } }
Response on published post: HTTP 409, { error: "post is not scheduled (current status: published)" }
Analytics
blog.get_analytics
Get analytics for a post.
Scope: blog:posts.read
Input: { id: string, from?: string, to?: string }
Response:
{
"post": { "id": "post_xxx", "title": "...", "status": "published", "publishedAt": "..." },
"analytics": { "views": 0, "reads": 0, "avgScrollDepth": null },
"collected": false,
"note": "Analytics ingestion is not yet enabled — values are placeholders."
}NOTE
The real analytics pipeline isn't yet wired — collected: false until ingestion ships.
For a complete end-to-end agent flow, see the content pipeline recipe.