# Publishing API, and images

Two changes, requested after the editor was built:

1. An external agent (ChatGPT on a schedule) writes the stories and
   POSTs them in, instead of the built-in AI editor.
2. Stories carry an image.

Both reverse earlier decisions. Recording that, because the original
reasoning is still partly valid and the reversal should be deliberate:

> **Image generation per post** — Cost + slop. Use company logos and
> typography. — `docs/PLAN.md`

---

## 1. What changes, and what does not

AI Radar becomes **the publishing platform**, not the editor. The parts
that stay are the parts that were never about judgement:

| Stays | Why |
|---|---|
| Ingestion, prefilter, entity dedup | Deterministic code, costs nothing, and still useful — it can feed the external agent a clean candidate list |
| The 4-field briefing shape | `what_changed` / `why_it_matters` / `who_should_care` / `worth_trying` is the product, whoever writes it |
| Duplicate detection | Still applies: an external agent will re-post the same event, and entity gating catches it |
| Publishing, translation, alerts, the site, the app | Unchanged — they read `posts` and do not care who wrote the row |
| The admin | Unchanged, and now more important: it is where you see what the agent published |

| Goes quiet | Why |
|---|---|
| The built-in editor (`/admin/run/edit`) | Still there and still works. It simply is not the path being used, and with no Anthropic key it cannot run anyway |
| Budget guard | Only guards AI spend this project makes. External agent spend is on your OpenAI bill, invisible here |

**The built-in editor is not deleted.** It costs nothing to leave in
place, the dry-run harness still exercises it, and if the external agent
turns out to produce worse output you have somewhere to fall back to.

---

## 2. What is lost, and what replaces it

Being plain about this, because it is the real cost of the change.

The built-in pipeline had three safety properties the external agent does
not automatically inherit:

| Property | Built-in | External agent |
|---|---|---|
| **Hallucination check** | A second model verified the draft against the source text and held anything below 70 confidence | Gone — nothing here has seen the source |
| **Spend cap** | Checked before every call, hard stop | Gone — on your OpenAI bill |
| **Duplicate detection** | Entity-gated, ran before any spend | **Kept** — runs on submission instead |

So the submission endpoint has to do the work the verifier used to:

- **`source_url` is required.** A briefing with no source is unpublishable
  by definition, and it is what makes a claim checkable later.
- **Every submission is deduplicated** against existing posts by the same
  entity gates the pipeline used. A repeat is rejected, not published.
- **Submissions can be held rather than published.** `status: "held"`
  puts it in the admin queue for you to read first. Recommended until
  you trust the agent's output.
- **The daily cap still applies.** `MAX_PUBLISH_PER_DAY` is enforced on
  this path too, so a runaway schedule cannot flood the site.

What is genuinely gone is automated verification of claims against a
source. If the agent invents a benchmark number, nothing here will catch
it. `status: "held"` plus the admin is the replacement, and it is a human
one.

---

## 3. Authentication

**A bearer API key, not the admin cookie.** A scheduled task cannot hold
a session, and the admin password should not be sitting in a ChatGPT
action config.

- `PUBLISH_API_KEY` in the environment, compared in constant time.
- Sent as `Authorization: Bearer <key>`.
- Separate from `ADMIN_PASSWORD`: leaking the publish key should not
  hand over the pipeline controls or the spend settings.
- The key only grants *submission*. It cannot read the audience, change
  sources, trigger runs, or unpublish anything.

---

## 4. The endpoint

```
POST /api/publish
Authorization: Bearer <PUBLISH_API_KEY>
Content-Type: application/json
```

```jsonc
{
  "title": "OpenAI ships GPT-5.2 with a 2M context window",
  "summary": "One or two sentences on what this is.",
  "what_changed": "The specific, factual change.",
  "why_it_matters": "Why a developer or founder should care.",
  "who_should_care": ["developers", "founders"],
  "worth_trying": "yes",
  "worth_trying_note": "Available today on the API.",

  "category": "model",
  "source_url": "https://openai.com/index/gpt-5-2/",   // required
  "source_name": "OpenAI",
  "source_published": "2026-09-12T10:00:00Z",

  "company": "openai",          // slug; created if unknown
  "importance": 88,             // 0-100
  "tags": ["gpt", "context-window"],

  "image_url": "https://openai.com/og.png",  // optional
  "image_alt": "GPT-5.2 announcement",
  "image_credit": "OpenAI",

  "status": "held",             // "held" (default) or "published"
  "locale": "en"                // the language this was written in
}
```

**Response**

```jsonc
{ "ok": true, "id": 42, "slug": "openai-ships-gpt-5-2", "status": "held" }
```

```jsonc
{ "ok": false, "error": "duplicate", "existingId": 17 }
{ "ok": false, "error": "daily cap reached" }
{ "ok": false, "error": "source_url is required" }
```

Rejections are `200 OK` with `ok: false`, not 4xx. A scheduled task
treating a duplicate as a failure would retry it, and retrying a
duplicate forever is worse than being told plainly.

---

## 5. Images

**Stored as a URL, not a file.** No upload, no storage bill, no image
pipeline. The three sources that make sense:

1. **The source page's `og:image`** — a real screenshot or diagram the
   company published. Best option, free, and accurate.
2. **A company logo** — already in the `companies` table.
3. **Whatever the agent supplies**, if it has something better.

**Extraction is built in.** Ingestion already fetches article HTML for
the body text; it now also reads `og:image` and stores it on the raw
item, so a submission that omits `image_url` can fall back to the real
one from the source.

### Rules

- **Never required.** A story with no image renders exactly as today —
  the design does not depend on one, and a broken image placeholder is
  worse than none.
- **HTTPS only**, and the URL is validated as a URL before storage. A
  `javascript:` or `data:` URL in an `<img src>` is an injection vector.
- **Alt text is required when an image is present.** An image with no alt
  is inaccessible, and the brief's design rules put readability first.
- **Remote, with a CSP allowlist.** Next.js needs `remotePatterns`
  configured, so an image host that is not allowed simply does not
  render rather than breaking the page.
- **Never generated.** The original objection stands: a generated image
  per post is cost plus slop. If the agent sends one anyway, it is
  stored — but nothing here will make one.

### Where it appears

| Surface | Image |
|---|---|
| Story page | Below the title, above the summary. Never between them |
| Hero card | Optional thumbnail; the card still works without |
| Standard cards | No image. Six thumbnails in a grid is a feed, not a briefing |
| `og:image` meta | Yes — this is where it earns most of its value, in link previews |
| Telegram / email | Yes for instant alerts, no for the digest (five images is a newsletter) |
| App | Story screen only |

---

## 6. Build order

- **9a** — `image_url` / `image_alt` / `image_credit` columns; og:image
  extraction during ingest
- **9b** — `POST /api/publish` with bearer auth, validation, dedup,
  daily cap
- **9c** — images on the site, app, and in alerts
- **9d** — admin: submissions queue, and an image on the post editor
- **9e** — a ChatGPT Actions schema (OpenAPI) so the scheduled task can
  call this without hand-written HTTP

---

## 7. Decisions taken without asking

Flagging these rather than burying them:

1. **Default `status` is `"held"`, not `"published"`.** An agent that can
   publish directly to a live site with no verification step is one bad
   prompt away from embarrassing output. Send `"published"` explicitly
   once you trust it.
2. **Images are URLs, not uploads.** Uploads mean storage, a CDN, and a
   bill — against the under-$10/month constraint for something the
   source page usually already provides.
3. **The built-in editor stays.** Deleting it would be irreversible and
   saves nothing.
4. **Dedup still runs.** An external agent will re-post the same event
   across runs; without this the site would fill with repeats.
