Twelve tools cover every blog operation an agent might want — discovery, 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.
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.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
Permanently delete a post. Tags are cascade-deleted from the junction table; the category itself is untouched.
Scope: blog:posts.delete
Input: { id: string }
Response: { id: string, deleted: true }
CAUTION
This is irreversible. Agents should confirm with the user before calling.
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.