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>
291 lines
12 KiB
Markdown
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"}` |
|