For custom sites we'll post to via their own API — there's no wired-in publisher for these, so this is where the site owner documents how to actually post there (auth, endpoints, payload shape). Shown in the UI for non-WordPress sites, exposed via the JSON API, and pointed to from AI_API.md's "any other network/CMS" fallback section so an agent checks it before improvising. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
304 lines
13 KiB
Markdown
304 lines
13 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, optional for every type. If set on a
|
|
type=blog post, WordPressPublisher uploads it and uses it as the
|
|
featured image — but it's fine to publish a blog post without one.
|
|
└─ 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",
|
|
"api_instructions": null,
|
|
"created_at": "2026-07-19T12:00:00.000000Z",
|
|
"updated_at": "2026-07-19T12:00:00.000000Z"
|
|
}
|
|
}
|
|
```
|
|
|
|
`api_instructions` is a free-text field (nullable) for `cms_type=custom`/`other`
|
|
sites — there's no built-in publisher for these yet, so this is where the
|
|
user documents how to actually post there: auth method, endpoints, payload
|
|
shape, whatever's needed. **Read it before improvising** when you're about
|
|
to publish to a site with no wired-in publisher (see "Any other
|
|
network/CMS" below) — it's the site owner telling you directly how their
|
|
API works, rather than you having to guess or ask.
|
|
|
|
`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, optional for every type — if set on a `type=blog` post it's uploaded to
|
|
WordPress as the featured image, but publishing works fine without one.
|
|
`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,
|
|
`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. If `post.image_url` is set: downloads it, 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. Skipped entirely if there's no image — that's fine, the post
|
|
just won't have a featured image.
|
|
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 the
|
|
uploaded media id if there was one.
|
|
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 any 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` — check the site's
|
|
`api_instructions` first, that's exactly what it's there for — 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"}` |
|