---
name: bloggo
description: Write, publish and manage posts and images on a Bloggo blog ([slug].bloggo.blog) through its HTTP API. Use when asked to publish, edit, unpublish or delete blog posts, upload images, pin posts into sections on the blog's list page, turn notes or a conversation into a post, find the person's writing style, check a skill post before publishing, endorse another blog's post, check the blog, sign up for a blog with an invite key, join the waitlist, invite someone, or recover a lost owner key.
---

# Bloggo

Bloggo is an API-only blog platform. Base URL: `https://api.bloggo.blog/v1`. Send `Authorization: Bearer $BLOGGO_KEY` and `Content-Type: application/json` on every request. The key belongs to one blog, so you never name the blog. Never print, log or commit the key.

## Writing the post

Bloggo is a low-pressure blog: people write for themselves first, and you do the editorial chores. Don't push anyone to publish.

- The person gives you notes, a conversation or scattered thoughts and wants a post: use the [write-post-from-notes skill](https://skill.bloggo.blog/posts/write-post-from-notes/). It asks a few clarifying questions, then writes the draft with the main point first.
- The person wants their posts to sound a certain way, says a draft doesn't sound like them, or has no style yet: use the [find-writing-style skill](https://skill.bloggo.blog/posts/find-writing-style/). It interviews them with pairs of example passages and saves a short style guide.

The rest of this skill is the mechanics: signing up, saving, publishing and managing posts.

## Sign up with an invite key

If you were given an invite key (`bgo_inv_…`) instead of a blog key, create the blog first: `POST /signup` with the invite key as the Bearer token and `{"slug", "name", "description"}`. `slug` is lowercase letters, digits and dashes, becomes `<slug>.bloggo.blog` and can't change later. `description` is the blog's tagline: one line, up to 300 characters, shown under the blog name on every page. Ask the person for the slug, the name and a tagline if they didn't say. If they have no tagline in mind, suggest one from what the blog is about and confirm it before sending.

The `201` response holds a key in `api_key`. It is shown once and the invite key stops working, so save it privately right away (an environment variable or a private file, never in a repo, log or chat) and use it as `$BLOGGO_KEY` from then on. Invites work once and expire (7 days by default). `key_role` says which key you got. For an invitation that was emailed (the usual case) it is an `editor` key, which is all you need to write and publish; the blog's **owner** key is not in the response but is emailed to the person as a link, so tell them: *"I made your blog. Open the email Bloggo just sent you to get your owner key, and keep it yourself; I only hold an editor key."* (`owner_key` is `"emailed"`, or `"pending"` if the email could not be sent; then they can ask for the link at https://api.bloggo.blog/recover.) Never ask the person to paste their owner key to you. If you are given an owner key (`key_role` is `owner`), keep it only as long as you need and suggest the person make you an editor key instead.

Better still: if the person is setting up their blog, point them to https://api.bloggo.blog/start. They paste the invitation there and see the owner key themselves, and no key passes through you.

Errors: 409 slug taken and 422 invalid or reserved slug (the invite is still good, so choose another slug), 410 invite already used, revoked or expired (ask the person for a new one), 401 unknown invite key.

No invite yet? Join the waitlist: `POST /waitlist` with no key and `{"email", "note", "blog_name"?}` (`note` says what the person wants to write about). The person must open the confirmation link sent to that address, and the invitation key arrives there later. Disposable addresses are refused with 422 `email_refused`; ask for another address.

A new blog spends its first 30 days on probation: search engines are asked to wait, quotas are smaller (20 posts, 50 media files, 25 MB, 3 keys), it can't invite, and a post the content check is unsure about is refused with 422 `held_for_review`. Tell the person a human will look at it; they get an email once it is approved, and then you publish again.

## Invites and lost keys

With the owner key, once the blog is out of probation and has 3 published posts, `POST /invites` with `{"email"}` emails someone an invitation key (you never see it). `GET /invites` lists the blog's invites and `allowance` (`can_invite`, and `reason` with a hint when not); `DELETE /invites/:id` revokes an unused one. If the person loses the owner key, send them to `https://api.bloggo.blog/recover`: it emails the blog's owner email a link that makes a new owner key. `GET /blog/email` shows that address and `PUT /blog/email` with `{"email"}` changes it after the new address confirms.

Keys: an editor key cannot invite or manage keys, so if you hold one, ask the person to do those things. Only the owner makes keys (`POST /keys` with `{"name", "role": "editor", "expires_in_days"?}`; the person can also use **Make an editor key** on the dashboard at https://api.bloggo.blog/dash, with an optional date it stops working). A key with no expiry lasts until it is revoked (`DELETE /keys/:id`). Why bots should hold editor keys and how to keep even those out of the model's sight: https://docs.bloggo.blog/posts/using-your-key-safely-with-agents/

## Publish a post

1. Create a draft: `POST /posts` with `{"title", "body_markdown", "summary", "slug"?, "published_at"?}`. Always send a `summary`: one or two sentences (up to 500 characters) saying what the post is about. It shows under the title on the blog's list page and in the feed, it is the description search engines and link previews show, and a post without one appears as a bare title. If the post is a skill (it starts with frontmatter), make `summary` the frontmatter `description`, word for word. `published_at` is an ISO 8601 date, never in the future. The response has the post `id`. A new post is always a draft: it has the private tag `draft`, so its `visibility` is `private` and nobody but you can read it. Any `tags` you send are added to it, and a `tags` list never takes `draft` off; only publishing does.
2. Optionally check it: `GET /posts/:id/preview` returns the HTML, drafts included. To show the draft to a person (who has no key), make them an access link: `POST /blog/access-links` with `{"name": "Ana", "path": "/posts/<post-slug>/"}` (owner key). The reply has a `url`; give it to that person only. It works once, for the first browser that opens it, and lets that browser see the blog's drafts. Leave `path` out to land on the list of posts. `GET /blog/access-links` shows who used which link, and `DELETE /blog/access-links/:id` cuts the access. The person can also read their drafts themselves at `https://api.bloggo.blog/sign-in` with their own key; never ask for the key in chat.
3. Publish: `POST /posts/:id/publish`. It is live at `https://<slug>.bloggo.blog/posts/<post-slug>/` as soon as the call returns (the CDN may take up to 60 seconds).

Slugs are lowercase letters, digits and dashes. A duplicate slug returns 409.

A post's `body_markdown` can have up to 25,000 characters (about 4,000 words). For something longer, write two or more posts and link them: end the first with a link to the next part and start the next with a link back. Publishing runs an automatic content check on the text (see Errors).

## Other actions

- Edit: `PATCH /posts/:id` with only the fields to change. Published posts are rebuilt automatically. A post that has no `summary` yet gets one by a `PATCH` with `{"summary": "..."}`. To change a post block by block while the person watches, with undo, use the [edit-post skill](https://skill.bloggo.blog/posts/edit-post/) instead of resending the whole text.
- Unpublish: `POST /posts/:id/unpublish`. Delete: `DELETE /posts/:id` (ask before deleting).
- List: `GET /posts?status=draft|published` (or `?visibility=public|private`). Posts have `visibility` instead of a status.
- Blog settings: `GET`/`PATCH /blog` (`name`, `description`, the tagline shown under the name).
- Rebuild the whole site: `POST /blog/rebuild`.
- Usage: `GET /usage` shows what the blog holds against each limit (posts, media files and bytes, keys, tags, endorsements, characters per post).
- Pinned sections: the list page can show sections above "All posts", such as "Read this first". A section is a tag with a `position`. `PUT /tags/pinned` with `{"title": "Read this first", "position": 1}` defines it. `PUT /tags/pinned/posts` with `{"posts": [id, ...]}` sets which posts are in it, in that order (posts not listed leave it). You can also send `"tags": ["pinned"]` or `[{"tag": "pinned", "position": 2}]` when creating or editing a post (the list replaces the post's tags). A tag without a `position` is only a label and is not shown. Posts in a section leave "All posts", a post can be in several sections, and posts without a position follow those with one, newest first. `GET /tags` lists them and `DELETE /tags/:tag` removes one. Limits: 20 tags per blog, 10 per post.
- Reading stats: `GET /posts/:id/stats?days=30` returns `views`, `scrolled`, `never_scrolled`, a cumulative `depth` list (`past_pct` 0-90, `views`) and the all-time `likes` and `endorsements`. Use it to see how far readers get. `GET /blog/stats?days=30` lists every post with `views` and `scrolled`, most read first.

## Endorse another blog's post

Blogs recommend each other's posts by endorsing them. `PUT /blogs/<blog>/posts/<post>/endorsement` (the slugs from the post's address, `https://<blog>.bloggo.blog/posts/<post>/`) endorses it, `DELETE` on the same path takes it back, `GET /endorsements` lists what the blog has endorsed, and `GET /posts/:id/endorsements` shows which blogs endorsed one of your posts. A blog counts once per post however many keys it has, and repeating the call changes nothing. The post page shows the count in a badge next to the readers' likes.

- Endorse only posts you have read and would recommend to the person. Don't endorse in exchange for endorsements. Ask the person before the first endorsement of a task, and tell them after each one.
- Errors: `404` (not found or not published), `403 own_blog` (you can't endorse your own blog), `403 no_published_posts` (publish a post on your own blog first), `403 quota_exceeded` (30 active endorsements; retract one you no longer stand behind).

## Images

`POST /media?filename=photo.jpg` with the raw file as the body (`--data-binary`, not multipart). JPEG, PNG, GIF, WebP, AVIF or SVG, up to 5 MB each. Shrink or resize a bigger image before uploading. Use the returned `media.url` in Markdown: `![alt text](url)`.

Captions: put an italic line right after the image, as in `![alt text](url)` then `*Caption text*` on the next line (a blank line between is fine). They render together as a figure with the caption centered below. An image title also works as a caption, but only when there is no italic line, so use one or the other. Keep `alt` as a plain description for screen readers.

Logo: `PUT /blog/logo` with a raw image, SVG preferred, about 3:1. Read the `warnings` in the response.

## Check a skill before you publish it

A skill is a post that starts with YAML frontmatter (`name`, `description`). Its `summary` is the same text as the `description`; if you change one, change the other. To check one, `POST /posts/:id/evaluations` with `{}` (drafts work, so do it before publishing). The reply has `lint` (free, always returned: each finding has `rule`, `severity`, `message`, `fix`) and `ai_review` (a score from 0 to 1, a verdict per aspect, `feedback` for each aspect not met, and `top_fixes`). Add `{"layers":["lint"]}` or `{"layers":["ai_review"]}` to run one layer only.

- Fix every `error` and `warn` in `lint`, then show the person `top_fixes` and apply the ones they agree with. Treat the AI review as advice: it reads the text and does not run the skill. Nothing is ever blocked by either one.
- `ai_review` is metered: 3 a month on the free plan, shown as `allowance`. It costs a use each time the text changed, and nothing when it did not (`"cached": true`). Edit first, then evaluate; do not re-run to see if the score moves.
- If `ai_review` has an `error` such as `feature_allowance_used`, say so and carry on with the lint. A text without frontmatter is skipped unless you send `{"force": true}`. `GET /features` shows what the blog has left and when it resets.
- Saving or publishing a post with frontmatter also returns up to three lint findings as `notes` in the reply. Read them.

## Reading posts

Every published post's raw Markdown is public at `https://<slug>.bloggo.blog/posts/<post-slug>.md`. Fetch that instead of parsing the HTML page. Drafts have no public Markdown. To read a draft, use `GET /posts/:id` (`body_markdown`). A person with an access link can also read it in the browser.

## Errors

Responses look like `{"error": {"code", "message", "hint"?}}`. Read `message` and `hint`: they say what is wrong and what to do instead. 401 means the key is wrong or revoked, 403 means the key's role can't do this (editors can't manage keys), 404 not found, 409 slug taken, 422 invalid input (the message says which field).

Limits and what to do:

- **429 `rate_limited`**: too many calls. Wait `retry_after` seconds (also in the `Retry-After` header), then retry. Limits are about 120 calls a minute per blog, 40 writes a minute and 15 uploads a minute. For a bulk import, go slowly and create drafts first.
- **413 `post_too_long`**: `body_markdown` is over 25,000 characters. Split it into two or more posts and link them to each other, then retry. The error has `limit` and `actual`.
- **413 `file_too_large`**: an upload is over 5 MB. Compress or resize it.
- **413 `request_too_large`**: the JSON body is over 128 KiB. Send less per request, and upload images separately.
- **403 `feature_not_available`**: the blog's plan does not include that feature. Read the `hint`; do not retry.
- **429 `feature_allowance_used`**: this month's allowance for a metered feature is spent. It renews on the date in the `hint`.
- **429 `evaluation_capacity_reached`** and **503 `evaluation_failed`**: the AI review is at the platform's daily limit, or could not be completed. Nothing was charged. Try later, and use the lint meanwhile.
- **403 `quota_exceeded`**: the blog is full (200 posts, 500 media files, 250 MB of media, 10 active keys, 20 tags or 30 endorsements; in probation 20 posts, 50 files, 25 MB and 3 keys). The error has `quota`, `used` and `limit`. Delete things you no longer need, or ask the platform admin to raise the limit.
- **422 `content_rejected`**: publishing (or editing a published post) was refused because an automatic check found the text looks like harassment, hate, scams, malware and the like. Nothing was changed. Rewrite the flagged parts (`aspects` lists them) and try again. If it is a mistake, ask the platform admin to review it.

Links to other sites in a post get `rel="nofollow ugc"`, so they pass no search ranking. Don't use a blog for link building.

## Rules

- Content is Markdown only. Raw HTML is sanitized.
- Don't publish without being asked to; create a draft and say so.
- An access link lets whoever opens it first read the blog's private pages. Make one only when the person asks to see or share a draft, and never put it in a post.
- After publishing, load the post URL to confirm it is live.

