Files
muninn/AI_API.md
T
m105andClaude Sonnet 5 42abc658f2 Add an image field, required for WordPress posts
Posts get a nullable image_url. WordPressPublisher now downloads it
and uploads it to WordPress's media endpoint before creating the
post, setting it as featured_media — and rejects publishing (without
touching status) if a blog post has no image set, mirroring the
existing title requirement. Every other network treats it as optional.

Not enforced at save time, same as title — you can still draft an
incomplete WordPress post, it just can't be published until an image
is set. Exposed through the UI form (with a thumbnail/""missing""
indicator in the table), the JSON API, and the Python CLI wrapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 20:03:25 +03:00

291 lines
12 KiB
Markdown

# API Guide for AI Agents
This app is a personal content-operations hub. It stores **Sites**,
**Social Accounts**, and **Posts**, and exposes them over a local JSON API
so an AI agent (Claude Code, Mistral, or any other LLM/tool-calling agent)
can read the user's data and drive the content lifecycle — draft, schedule,
publish, report back.
**There is no authentication on this API.** It is designed to run on the
local network only. Do not expose it to the public internet. Because there
is no auth, credentials (social account passwords, WordPress Application
Passwords) are returned in **plain text** by design — the agent is expected
to use them directly (e.g. to log into Reddit, or call WordPress's REST
API) without an extra decryption step.
Base URL: `{APP_URL}/api` — e.g. `http://localhost:8000/api` in local dev.
All requests/responses are JSON. Send `Accept: application/json` (Laravel
defaults to JSON error responses for API routes regardless, but set it to
be safe on validation-error bodies).
**If you're an agent running on the machine this app is deployed to**, you
don't need to build HTTP requests by hand — use the CLI wrappers in
`python_scripts/` (`sites.py`, `accounts.py`, `posts.py`; see
`python_scripts/README.md`). They default to `http://localhost:8001` and
print the same JSON shapes documented below.
## Mental model
```
Site (a website the user owns)
└─ cms_type: wordpress | custom | other
└─ if wordpress: wp_username + wp_app_password (WP Application Password)
SocialAccount (login for a social network)
└─ network: twitter | instagram | facebook | reddit | tiktok | linkedin | youtube | other
Post (one piece of content, belongs to a Site, optionally a SocialAccount)
└─ type: same vocabulary as SocialAccount.network, plus "blog"
└─ target: nullable, network-specific destination (e.g. a subreddit for type=reddit)
└─ image_url: nullable URL. Required to publish type=blog (WordPress) —
used as the featured image. Optional for every other type.
└─ status: draft -> scheduled -> publishing -> published | failed
└─ published_url: set once it's actually live somewhere
└─ publish_error: set to the failure reason when status=failed (or when a
publish attempt is rejected back to draft — see below)
```
The typical agent loop is:
1. Read Sites/Social Accounts to know what you're working with (`GET /api/sites`, `GET /api/accounts`).
2. Create a Post as a draft (`POST /api/posts`), or find one that's already `due` (`GET /api/posts?due=1`).
3. Publish it externally, by whatever mechanism fits its `type`/site (see "How to actually publish" below).
4. Report the outcome back with `PATCH /api/posts/{id}` — set `status` to `published` or `failed`, and `published_url` if you have one, **unless** you used the `posts:publish` command or the app's own Publish button/job, which already does this step for you.
`publishing` is a transient status set automatically while a background publish
job (triggered by the app's own "Publish" button in the UI) is running — you
won't normally set it yourself. If the underlying publisher rejects the post
before attempting anything (missing title, no target, credentials not set),
the job puts it back to `draft` rather than `failed`, since nothing was
actually attempted; check `publish_error` for why.
## Sites
Read-only. No create/update/delete via API — sites are managed through the
app's UI at `/sites`.
`GET /api/sites`
Optional query param: `?name=` (partial, case-sensitive-per-DB-collation match on name).
`GET /api/sites/{id}`
Response shape:
```json
{
"data": {
"id": 1,
"name": "My Blog",
"url": "https://example.com",
"description": "A blog about things",
"cms_type": "wordpress",
"wp_username": "admin",
"wp_app_password": "abcd 1234 efgh 5678",
"created_at": "2026-07-19T12:00:00.000000Z",
"updated_at": "2026-07-19T12:00:00.000000Z"
}
}
```
`cms_type` is one of `wordpress`, `custom`, `other`. `wp_username`/
`wp_app_password` are only meaningful (non-null) when `cms_type` is
`wordpress` — they're a WordPress **Application Password** pair (see
"WordPress publishing" below), not a regular account password.
## Social Accounts
Read-only, same reasoning as Sites — manage via `/social-accounts` in the UI.
`GET /api/accounts`
Optional query params: `?network=` (exact match), `?type=` (exact match).
`GET /api/accounts/{id}`
Response shape:
```json
{
"data": {
"id": 1,
"network": "reddit",
"username": "someuser",
"email": "someuser@example.com",
"password": "plaintext-password",
"type": "post",
"created_at": "...",
"updated_at": "..."
}
}
```
`network` is one of `twitter`, `instagram`, `facebook`, `reddit`, `tiktok`,
`linkedin`, `youtube`, `other`. `type` is one of `post`, `comment`, `like`,
or `null`. The `password` field is the real plain-text login password for
that network — treat it as a live credential.
## Posts
Full CRUD — this is the resource the agent actively drives.
`GET /api/posts`
Query params (all optional, combine freely):
- `?status=` — exact match: `draft` | `scheduled` | `publishing` | `published` | `failed`
- `?site_id=` — exact match
- `?social_account_id=` — exact match
- `?due=1` — the important one for automation: returns posts where
`status=scheduled AND scheduled_at <= now()`. **Poll this to find work.**
`GET /api/posts/{id}`
`POST /api/posts` — create a post.
Body:
```json
{
"site_id": 1,
"social_account_id": null,
"type": "blog",
"title": "Optional title",
"body": "Required content",
"target": null,
"image_url": null,
"status": "draft",
"scheduled_at": null
}
```
Validation: `site_id` required, must exist. `social_account_id` nullable,
must exist if given. `type` required, one of `twitter`, `instagram`,
`facebook`, `reddit`, `tiktok`, `linkedin`, `youtube`, `blog`, `other`.
`body` required. `title` nullable. `target` nullable — a network-specific
destination, e.g. the subreddit name for `type=reddit`. `image_url` nullable
URL — not enforced at save time (you can draft a `type=blog` post without one),
but `WordPressPublisher` will reject publishing it until it's set. `status`
defaults to `draft` if omitted. `scheduled_at` nullable datetime.
Note: `type` and the linked Social Account's `network` are **not**
cross-validated — you can link a `type=blog` post to a Twitter account, or
no account at all. Set `type` to whatever actually describes the content;
it's what later determines *how* the post gets published (see below).
`PATCH /api/posts/{id}` — partial update. This is how you **report a
publish result** back for anything you didn't publish via the
`posts:publish` command or the app's own Publish button:
```json
{ "status": "published", "published_url": "https://example.com/2026/07/my-post" }
```
or on failure:
```json
{ "status": "failed" }
```
`DELETE /api/posts/{id}`
## How to actually publish a post
Publishing happens through a shared `Publisher` abstraction
(`app/Support/Publishing/`) — one implementation per network family
(`WordPressPublisher`, `RedditPublisher`, more to come), resolved by `Post.type`
via `PublisherManager`. There are three ways to trigger it, depending on
who/what is driving:
- **From the UI**: the Posts page has a "Publish" button (for any post whose
type currently has a publisher). Clicking it sets `status=publishing` and
dispatches `PublishPostJob` on the queue — a worker must be running
(`php artisan queue:work`) for anything to actually happen. The UI polls
and updates itself once the job finishes.
- **From the CLI**, for WordPress specifically: `php artisan posts:publish
{post_id}` (unchanged, see below) — synchronous, no queue needed.
- **Manually**, for anything without a wired-in publisher yet: do it
yourself and `PATCH` the result back (see below).
Note the asymmetry: WordPress is synchronous (a few plain HTTP calls, fast
enough to run inline), Reddit is not (it's real browser automation via
Playwright, tens of seconds) — which is exactly why the UI path is
queue-backed rather than a direct synchronous call.
Publishing mechanism depends on the post's target:
### WordPress sites (`type=blog`, site has `cms_type=wordpress`)
Don't call the WordPress REST API yourself from a generic HTTP request —
use the built-in Artisan command, which handles auth, validation, and
status/`published_url` bookkeeping for you:
```bash
php artisan posts:publish {post_id}
```
What it does:
1. Loads the Post and its Site.
2. Validates: `post.type` must be `blog`, `post.title` must be set,
`post.image_url` must be set, `site.cms_type` must be `wordpress`, and
the site must have `wp_username`/`wp_app_password` set. Any failure here
exits 1 with a descriptive message and **does not touch** `Post.status`.
3. Downloads `post.image_url`, then POSTs it to
`{site.url}/wp-json/wp/v2/media` (HTTP Basic Auth with the site's
WordPress Application Password) to upload it as media and get back a
media id.
4. POSTs to `{site.url}/wp-json/wp/v2/posts` with `status: publish` (goes
live immediately, not a WP draft) and `featured_media` set to that id.
5. On success: sets `Post.status = published`, `Post.published_url` to the
new post's permalink, prints `{"success": true, "url": "..."}`, exits 0.
6. On failure at either HTTP step (bad credentials, unreachable site,
unreachable/rejected image, WordPress error): sets `Post.status = failed`,
prints `{"success": false, "error": "..."}`, exits 1.
This is a **shell command**, not an HTTP endpoint — run it from wherever
you have shell access to this app (e.g. `php artisan posts:publish 42`).
You do not need to call `PATCH /api/posts/{id}` afterward; the command
already updates the Post's status and `published_url`.
If a WordPress site is missing `wp_username`/`wp_app_password`, the user
needs to generate an Application Password in their WP admin (**Users →
Profile → Application Passwords**) and set it on the Site via this app's
`/sites` UI — this is not something an agent can do on the user's behalf.
### Reddit (`type=reddit`)
Use the standalone sidecar at `sidecars/reddit-post/`, a Playwright script
(not part of this Laravel app, no shared DB access):
```bash
./sidecars/reddit-post/run.sh \
--username=USERNAME --password=PASSWORD \
--subreddit=test --title="..." --body="..."
```
It logs into Reddit and creates a text post, printing the same
`{"success": ..., "url"/"error": ...}` JSON contract on stdout. **Unlike
`posts:publish`, this sidecar does not know about the Posts table at
all** — get the Social Account credentials from `GET /api/accounts`
first, then after the sidecar returns, report the result yourself via
`PATCH /api/posts/{id}` (`status` + `published_url` from the sidecar's
`url`, or `status: failed` if it errored).
### Any other network/CMS (`custom`, `other`, or a `type` with no
integration yet)
There is no built-in publishing path. Do it however is appropriate for
that platform (its own API, its own automation), using the credentials
from `GET /api/sites` / `GET /api/accounts`, then report back via
`PATCH /api/posts/{id}` exactly as described above. This is the general
fallback contract: **whatever publishes a post, always ends with either
the `posts:publish` command's automatic bookkeeping, or a manual `PATCH`
setting `status` and, if applicable, `published_url`.**
## Quick reference
| Resource | List | Get one | Create | Update | Delete |
|---|---|---|---|---|---|
| Sites | `GET /api/sites` | `GET /api/sites/{id}` | — | — | — |
| Social Accounts | `GET /api/accounts` | `GET /api/accounts/{id}` | — | — | — |
| Posts | `GET /api/posts` | `GET /api/posts/{id}` | `POST /api/posts` | `PATCH/PUT /api/posts/{id}` | `DELETE /api/posts/{id}` |
| Task | How |
|---|---|
| Find work to publish | `GET /api/posts?due=1` |
| Publish from the UI | click "Publish" on the Posts page (queues `PublishPostJob`; needs a queue worker running) |
| Publish to a WordPress site | `php artisan posts:publish {post_id}` (handles status/published_url itself) |
| Publish to Reddit | `sidecars/reddit-post/run.sh ...`, then `PATCH /api/posts/{id}` yourself |
| Publish anywhere else | do it yourself, then `PATCH /api/posts/{id}` yourself |
| Report a failed publish | `PATCH /api/posts/{id}` with `{"status": "failed"}` |