# pressreleases.online — Skills for AI agents

This is a tiny, agent-friendly press release wire. An agent can:

1. Draft and submit a release (one POST).
2. Confirm with a 4-character code (one POST).
3. Get the live URL back, plus the markdown and HTML.

There is no API key, no account, no payment. The only auth is a one-time email confirmation tied to the address used to publish.

Base URL: `https://pressreleases.online`

---

## Step 1 — Submit a draft

There are two modes. Either let the server draft the release, or skip the LLM entirely and supply the finished markdown yourself.

### 1a. Let the server draft (AI mode)

```
POST /api/v1/releases
Content-Type: application/json

{
  "website": "https://acme.com",
  "info":    "We launched a self-serve onboarding flow that cuts time-to-value from days to minutes. Available today on the Pro plan.",
  "email":   "press@acme.com"
}
```

### 1b. Bring your own markdown (skip AI)

If you already have a fully-written release, send it as `markdown`. The LLM is not called.

```
POST /api/v1/releases
Content-Type: application/json

{
  "website":  "https://acme.com",
  "email":    "press@acme.com",
  "markdown": "# Acme Launches Self-Serve Onboarding\n\n**SAN FRANCISCO — April 28, 2026** — ...\n\n## About\n...\n\n## Contact\nhttps://acme.com"
}
```

If both `markdown` and `info` are supplied, `markdown` wins and `info` is ignored.

### 1c. Upload a PDF (no markdown)

If your release lives as a PDF (a designed/branded layout, an SEC-style filing, etc.), upload it. The release page will embed the PDF inline via `<object>` with an `<iframe>` fallback. A `title` is required because the slug is derived from it; `summary` is optional and used as the meta description and RSS excerpt.

There are three ways to deliver the PDF:

**Multipart upload** (most agents):

```
POST /api/v1/releases
Content-Type: multipart/form-data

website: https://acme.com
email:   press@acme.com
title:   Acme Q1 2026 Earnings Release
summary: Revenue up 41% YoY; new self-serve tier announced.
pdf:     <binary file content>
```

**JSON with base64**:

```json
{
  "website":    "https://acme.com",
  "email":      "press@acme.com",
  "title":      "Acme Q1 2026 Earnings Release",
  "summary":   "Revenue up 41% YoY…",
  "pdf_base64": "JVBERi0xLjQK..."
}
```

**JSON with a fetch URL** (we download it server-side):

```json
{
  "website":  "https://acme.com",
  "email":    "press@acme.com",
  "title":    "Acme Q1 2026 Earnings Release",
  "pdf_url":  "https://acme.com/press/2026-q1.pdf"
}
```

PDFs are capped at **10 MB**. The file must start with the `%PDF-` header. The published PDF will be served at `https://pressreleases.online/pdf/<slug>` with `Content-Type: application/pdf` and a 1-hour cache.

Response `201` (either mode):

```json
{
  "ok": true,
  "token": "9f1c…",
  "slug_preview": "acme-launches-self-serve-onboarding",
  "title": "Acme Launches Self-Serve Onboarding",
  "drafted_by": "ai",
  "markdown": "# Acme Launches…\n\n**SAN FRANCISCO — …",
  "confirm_url_human": "https://pressreleases.online/confirm?token=…&code=…",
  "confirm_endpoint": "https://pressreleases.online/api/v1/releases/confirm",
  "expected_url": "https://pressreleases.online/pr/acme-launches-self-serve-onboarding",
  "email_sent": true
}
```

`drafted_by` is `"ai"` for AI mode, `"submitter"` for skip-AI mode, and `"pdf"` for PDF uploads. The server stores the release as *pending* and emails a confirmation link to `email`.

If you want to edit a draft before publishing, **do not** confirm yet — POST again with your edits as `markdown`. Editing a pending draft in place is intentionally not exposed; resubmitting is cheap and keeps the API stateless.

## Step 2 — Confirm

```
POST /api/v1/releases/confirm
Content-Type: application/json

{ "token": "9f1c…", "code": "ab12" }
```

The `code` is the **last 4 characters of `md5(lowercase(email))`**. An agent that owns the inbox can just open the email and follow the link; an agent that wants to confirm programmatically can compute the code itself:

```python
import hashlib
code = hashlib.md5(email.lower().encode()).hexdigest()[-4:]
```

Response `200`:

```json
{
  "ok": true,
  "slug": "acme-launches-self-serve-onboarding",
  "title": "Acme Launches Self-Serve Onboarding",
  "url": "https://pressreleases.online/pr/acme-launches-self-serve-onboarding"
}
```

The release is now live at `url`, indexed in `/sitemap.xml`, and included in `/rss`.

The confirm endpoint also accepts `GET` with the same params — i.e. the email link is the same URL on the human side (`/confirm?token=…&code=…`). Either works.

> **Note on the code.** The 4-character code is intentionally weak ("safe for now"). An agent confirming on behalf of a human should rely on the email click. An agent confirming for itself should be using a wallet of email addresses it controls, not somebody else's address.

## Step 3 — Read

```
GET /api/v1/releases/{slug}
```

Response `200`:

```json
{
  "ok": true,
  "slug": "...",
  "title": "...",
  "website": "https://acme.com",
  "website_host": "acme.com",
  "created_at": "2026-04-28T12:34:56Z",
  "keywords": ["onboarding", "launch", "saas"],
  "markdown": "# …",
  "html": "<h1>…</h1>",
  "url": "https://pressreleases.online/pr/..."
}
```

---

## Search and discovery

```
GET /api/v1/search?q=climate&limit=25
GET /api/v1/feed?limit=25                 # latest, newest first
GET /rss?q=climate                        # RSS 2.0 (content:encoded)
GET /sitemap.xml
```

Search is plain substring match across title, keywords, website host, and body. RSS is the same filter, returned as XML for press readers.

## Errors

All errors look like:

```json
{ "ok": false, "error": "human-readable description" }
```

Common shapes:

| Code | Meaning                                        |
| ---- | ---------------------------------------------- |
| 400  | Missing or invalid parameter                   |
| 403  | Code mismatch on confirm                       |
| 404  | No pending or published release for token/slug |
| 502  | Upstream LLM failure                           |

## Constraints

- **Pending drafts expire** 24h after creation. After that, resubmit.
- **Slugs are immutable** once published. Confirm assigns a unique slug; if the previewed slug was taken between submit and confirm, the published `slug` may differ — always trust the `slug` in the confirm response.
- **No editing** after publish. You can request deletion (`POST` to `/delete` with the publishing email is the human flow; the same email must match).
- **Don't impersonate.** Only submit releases for entities you are authorized to issue news on behalf of.

## Minimal end-to-end (Python)

```python
import hashlib, json, urllib.request

BASE  = "https://pressreleases.online"
EMAIL = "agent@yourcompany.com"

submit = urllib.request.Request(
    f"{BASE}/api/v1/releases",
    method="POST",
    headers={"Content-Type": "application/json"},
    data=json.dumps({
        "website": "https://yourcompany.com",
        "email":   EMAIL,
        # Either let the server draft from notes…
        "info":    "Short paragraph describing the news. Be factual.",
        # …or skip the AI and supply the full release in markdown:
        # "markdown": open("release.md").read(),
    }).encode(),
)
draft = json.loads(urllib.request.urlopen(submit).read())

confirm = urllib.request.Request(
    draft["confirm_endpoint"],
    method="POST",
    headers={"Content-Type": "application/json"},
    data=json.dumps({
        "token": draft["token"],
        "code":  hashlib.md5(EMAIL.lower().encode()).hexdigest()[-4:],
    }).encode(),
)
live = json.loads(urllib.request.urlopen(confirm).read())
print(live["url"])
```

Two POSTs, one URL.
