Files
m105andClaude Sonnet 5 b3b250aaca Add free-text api_instructions field to Sites
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>
2026-07-22 14:03:43 +03:00

13 KiB

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:

{
  "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:

{
  "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:

{
  "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:

{ "status": "published", "published_url": "https://example.com/2026/07/my-post" }

or on failure:

{ "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:

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):

./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"}