# NOOPS API Documentation

> Complete reference for the NOOPS public API — designed for agents, LLMs, and developers.

**Base URL:** `https://noops.au`

**Discovery endpoints:**
- `GET /api` — JSON directory of all endpoints (start here)
- `GET /llms.txt` — plain-text site manifest for LLMs
- `GET /.well-known/ai-plugin.json` — agent plugin descriptor
- `GET /rss` — RSS 2.0 feed (XML)

---

## Quick start

```bash
# Discover all endpoints
curl https://noops.au/api

# Get latest posts
curl https://noops.au/api/posts/feed

# Get a post in markdown
curl "https://noops.au/api/posts/my-post-slug?format=md"

# Search posts and signals
curl "https://noops.au/api/search?q=open+weights&mode=hybrid"

# Get editorial signals
curl https://noops.au/api/signals/editorial/feed

# Get combined signal dashboard
curl https://noops.au/api/signals/overview

# Get all tags
curl https://noops.au/api/tags

# --- Write endpoints (require bearer token) ---

# Create a new post
curl -X POST https://noops.au/api/posts/ingest \
  -H "Authorization: Bearer $EDITORIAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"My Post","slug":"my-post","body_md":"# Hello\nContent..."}'

# Update an existing post
curl -X PATCH https://noops.au/api/posts/POST_ID \
  -H "Authorization: Bearer $EDITORIAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status":"published"}'

# Upload an image
curl -X POST https://noops.au/api/images/upload \
  -H "Authorization: Bearer $EDITORIAL_API_KEY" \
  -F "file=@hero.webp"

# Create a database backup
curl -X POST https://noops.au/api/admin/backup \
  -H "Authorization: Bearer $EDITORIAL_API_KEY"
```

---

## Authentication

Most endpoints work **without authentication**, returning free-tier data. Authenticated users get more data based on their subscription tier.

| Tier | Access level |
|------|-------------|
| **free** (unauthenticated) | Post listings, excerpts of gated posts, latest 3 editorial signals, signal summaries with limited counts |
| **plus** | Full editorial signals, full signal data, history charts, daily newsletter |
| **pro** | Everything in plus, plus exclusive research and weekly wrap-ups |

**How to authenticate (readers):**

1. `POST /api/auth/login` with `{ "email": "..." }` to receive a magic link
2. Click the magic link to establish a session
3. Include the `noops_session` cookie in subsequent requests

### Personal API keys (programmatic access)

For agents and scripts, a personal API key avoids copying browser session cookies. Present it as a bearer token:

```
Authorization: Bearer noops_sk_xxxxxxxx...
```

Personal keys are prefixed `noops_sk_` (named keys) or `noops_ak_` (agent links). The middleware resolves the key to its owning member on every request, so the key carries exactly that member's tier — every tier-gated endpoint works transparently, no per-endpoint configuration. Self-served keys are read-only (GET/HEAD only) and never carry admin, whoever owns them; write- or admin-scoped keys are issued manually. Named keys are stored only as SHA-256 hashes and can be revoked instantly.

Self-serve (session-authenticated only — a key cannot manage keys):
- `GET /api/keys` — your agent link (auto-minted on first call) + named-key metadata
- `POST /api/keys` `{ name }` — mint a named read-only key; raw token returned once; cap 5
- `POST /api/keys/rotate` — replace your agent link; the old one dies instantly
- `DELETE /api/keys/:id` — revoke a key

The agent front door is `GET /agent` — content-negotiated (markdown for agents, HTML for browsers). A member's agent link (`/agent?key=noops_ak_...`) authenticates the first request via the query param; the page tells the agent to switch to the bearer header.

### Bearer token authentication (editorial agents)

Write-access endpoints (post creation, editing, image upload, backups) accept a bearer token for machine-to-machine auth. Set the `EDITORIAL_API_KEY` environment variable on the Cloudflare Pages project.

```
Authorization: Bearer <EDITORIAL_API_KEY>
```

Endpoints that accept bearer auth: `POST /api/posts/ingest`, `PATCH /api/posts/[id]`, `POST /api/images/upload`, `POST /api/admin/backup`, `GET /api/admin/backup`.

---

## Response format

All endpoints return JSON with:
- `Content-Type: application/json`
- `Access-Control-Allow-Origin: *` (CORS enabled)

**Error responses** follow the pattern:
```json
{
  "error": "upgrade_required",
  "message": "Description of what's needed",
  "upgrade_url": "/account.html?tier=plus&cycle=monthly"
}
```

**Dates** are in SQLite datetime format: `YYYY-MM-DD HH:MM:SS`

**Tags** are JSON-encoded arrays stored as strings. Parse with `JSON.parse(post.tags)`.

---

## Content endpoints

### GET /api/posts/feed

Paginated listing of published posts. Returns metadata only (no body content).

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | number | 20 | Results per page (max 50) |
| `offset` | number | 0 | Pagination offset |
| `category` | string | — | Filter by category (e.g. `essay`, `analysis`) |

**Response:**
```json
{
  "posts": [
    {
      "id": "abc123",
      "slug": "my-post-title",
      "title": "My Post Title",
      "subtitle": "Optional subtitle",
      "excerpt": "First paragraph or manual excerpt...",
      "author": "john",
      "category": "essay",
      "tier_access": "free,plus,pro",
      "tags": "[\"ai\",\"infrastructure\"]",
      "published_at": "2026-04-17 08:00:00"
    }
  ],
  "total": 42
}
```

**Notes:**
- `author` values: `john`, `mark`, `both`
- `tier_access` is comma-separated: `"free,plus,pro"` means accessible to all tiers
- `tags` is a JSON array encoded as a string — parse with `JSON.parse()`

---

### GET /api/posts/:slug

Single post by URL slug. Returns full body content for authorized tiers; excerpt only for gated content.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `format` | string | `html` | `html` for HTML body, `md` or `markdown` for markdown body |

**Response (authorized):**
```json
{
  "post": {
    "id": "abc123",
    "slug": "my-post-title",
    "title": "My Post Title",
    "subtitle": "Optional subtitle",
    "body_html": "<p>Full HTML content...</p>",
    "author": "john",
    "tags": "[\"ai\",\"infrastructure\"]",
    "category": "essay",
    "tier_access": "free,plus,pro",
    "featured_image": "/images/hero.webp",
    "excerpt": "First paragraph...",
    "published_at": "2026-04-17 08:00:00",
    "format": "html"
  },
  "gated": false
}
```

**Response with `?format=md` (authorized):**
```json
{
  "post": {
    "slug": "my-post-title",
    "title": "My Post Title",
    "body_md": "# Full markdown content\n\nWith **formatting**...",
    "format": "markdown"
  },
  "gated": false
}
```

**Response (gated — insufficient tier):**
```json
{
  "post": {
    "slug": "my-post-title",
    "title": "My Post Title",
    "body_html": null,
    "body_md": null,
    "excerpt": "Teaser excerpt...",
    "tier_access": "plus,pro"
  },
  "gated": true,
  "required_tier": "plus,pro"
}
```

**Agent tip:** Use `?format=md` — markdown is much cleaner for LLM consumption than HTML.

---

### GET /api/tags

All unique tags used across posts and editorial signals.

**Response:**
```json
{
  "tags": ["agents", "ai", "anthropic", "cybersecurity", "harness", "infrastructure", ...]
}
```

---

### GET /rss

Combined RSS 2.0 feed of free-tier posts and published editorial signals.

**Content-Type:** `application/rss+xml`

**Notes:**
- Only includes free-tier posts
- Signal items are prefixed with `[Signal]` in the title
- Cached for 15 minutes (`max-age=900`)

---

## Editorial signals

### GET /api/signals/editorial/feed

Published editorial signals — short-form analysis written by the NOOPS team.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `limit` | number | 20 | Results per page (max 50) |
| `offset` | number | 0 | Pagination offset |

**Response:**
```json
{
  "signals": [
    {
      "id": "sig123",
      "title": "ASML's 83% lithography monopoly",
      "body": "Full signal text with analysis...",
      "source_url": "https://example.com/article",
      "tags": "[\"infrastructure\",\"asml\"]",
      "published_at": "2026-04-17 09:30:00"
    }
  ],
  "total": 156,
  "tier": "free",
  "limited": true
}
```

**Tier gating:**
- **free:** Latest 3 signals only, `limited: true`
- **plus/pro:** Full archive with pagination, `limited: false`

---

### POST /api/signals/editorial/ingest

Push editorial signals into the system. Supports idempotent upsert via `external_id`.

**Auth:** Bearer token required

**Request:**
```json
{
  "signals": [
    {
      "title": "NVIDIA Q1 earnings beat expectations",
      "body": "Signal body text with analysis...",
      "source_url": "https://example.com/article",
      "author": "both",
      "tags": ["infrastructure", "nvidia"],
      "thesis_ids": ["thesis-compute"],
      "external_id": "v1-2026-05-21-https://example.com/article-nvidia-q1-earnings",
      "source_lines": "38311-38324, 38401-38412"
    }
  ]
}
```

**Required per signal:** `title`, `body`, `external_id`, `source_lines`

**Optional fields:**
| Field | Default | Description |
|-------|---------|-------------|
| `author` | `both` | `john`, `mark`, or `both` |
| `tags` | null | String array |
| `thesis_ids` | null | String array |
| `source_url` | null | Source article URL |
| `external_id` | required | Stable dedup key — if a draft with this external_id already exists, it is updated instead of duplicated |
| `source_lines` | required | One or more inclusive archive ranges; separate disjoint ranges with commas |

**Idempotent upsert behaviour:**

When `external_id` is provided:
- If no signal with that `external_id` exists → inserts a new row
- If a **draft** signal with that `external_id` already exists → updates title, body, source_url, author, tags, thesis_ids and source_lines
- If an approved, published or rejected signal has that `external_id` → leaves it unchanged and reports it in `skipped_frozen`

This makes it safe to re-run a pipeline without creating duplicates or rewriting decided content.

**Response (201):**
```json
{
  "ok": true,
  "ids": ["abc123...", "def456..."],
  "created": 8,
  "updated": 2,
  "updated_external_ids": ["v1-2026-05-21-https://example.com/article-nvidia-q1-earnings", "..."],
  "skipped_frozen": [{"external_id": "v1-...", "status": "published"}]
}
```

The `created` and `updated` counts let the caller verify exactly what happened. `updated_external_ids` and `skipped_frozen` appear only when applicable.

---

### GET /api/signals/editorial

Admin list of editorial signals with filtering. Useful for verification after a pipeline run.

**Auth:** Bearer token or admin session

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `status` | string | `draft` | Filter by status: `draft`, `approved`, `published`, `rejected` |
| `limit` | number | 50 | Results per page (max 100) |
| `offset` | number | 0 | Pagination offset |
| `created_after` | string | — | ISO timestamp — only signals created after this time |
| `external_id` | string | — | Exact match on external_id |

**Response:**
```json
{
  "signals": [ ... ],
  "filtered_count": 10,
  "counts": {
    "draft": 10,
    "approved": 5,
    "published": 156,
    "rejected": 3
  }
}
```

**Post-run verification pattern:**
```bash
# Check how many drafts were created today
curl "https://noops.au/api/signals/editorial?status=draft&created_after=2026-05-21T00:00:00" \
  -H "Authorization: Bearer $EDITORIAL_API_KEY"

# Check if a specific external_id exists
curl "https://noops.au/api/signals/editorial?status=draft&external_id=source-summary/2026-05-21-nvidia-q1" \
  -H "Authorization: Bearer $EDITORIAL_API_KEY"
```

---

## Signal data endpoints

These endpoints provide structured data on AI models, stocks, repos, and more. Most require plus or pro tier for full data.

### GET /api/signals/overview

Combined dashboard of all signal types in a single response. Best entry point for a broad view.

**Response structure:**
```json
{
  "tier": "free",
  "generated_at": "2026-04-17T10:00:00.000Z",
  "data_freshness": {
    "stocks": "2026-04-17 09:30:00",
    "github": "2026-04-17 08:00:00",
    "downloads": "2026-04-16 12:00:00",
    "ai-models": "2026-04-17 06:00:00",
    "openrouter": "2026-04-17 06:00:00",
    "ai-status": "2026-04-17 09:45:00",
    "huggingface": "2026-04-17 06:00:00"
  },
  "stocks": {
    "items": [{ "ticker": "NVDA", "company": "NVIDIA", "price": 142.50, "change_pct": 2.3, "ytd_pct": 15.7, "yoy_pct": 45.2, "high_52w": 155.00, "low_52w": 85.00, "sparkline": [130, 132, ...], "category": "compute", "thesis": "..." }],
    "total_available": 50
  },
  "github": {
    "items": [{ "repo": "anthropics/claude-code", "stars": 45000, "stars_delta": 150, "forks": 3200, "category": "agents" }],
    "total_available": 30
  },
  "ai_models": {
    "items": [{ "model_name": "Claude Opus 4.7", "provider": "Anthropic", "intelligence": 78.5, "coding": 85.2, "price_blended": 25.0, "output_speed": 45, "value_score": 3.14 }],
    "total_available": 100,
    "attribution": "Data from artificialanalysis.ai"
  },
  "openrouter": { "items": [...], "total_available": 500, "total_free": 120 },
  "huggingface": { "items": [...], "total_available": 200 },
  "ai_status": { "providers": [...], "recent_incidents": [...], "total_incidents_7d": 5 },
  "downloads": { "items": [...], "total_available": 20 },
  "rss": { "items": [...], "total_available": 500 },
  "clippings": { "items": [...], "total_available": 100 },
  "people": { "items": [...], "total_available": 15 },
  "entity_links": {
    "ticker_to_company": { "NVDA": "nvidia", "MSFT": "microsoft" },
    "feed_to_person": { "Simon Willison": "simon-willison" },
    "feed_to_company": { "Anthropic Blog": "anthropic" }
  }
}
```

**Tier gating:** Free users receive limited counts and summary data. Plus/pro get full lists.

---

### GET /api/signals/ai-models

AI model benchmark catalog. Ranked by intelligence score.

**Auth:** plus or pro required (free returns 403)

**Response:**
```json
{
  "tier": "plus",
  "models": [
    {
      "model_name": "Claude Opus 4.7",
      "provider": "Anthropic",
      "intelligence": 78.5,
      "coding": 85.2,
      "math": 72.0,
      "mmlu_pro": 80.1,
      "gpqa": 65.3,
      "hle": 45.2,
      "live_code_bench": 55.8,
      "price_blended": 25.0,
      "price_input": 15.0,
      "price_output": 75.0,
      "output_speed": 45,
      "ttft": 1200,
      "first_seen": "2026-04-15",
      "fetched_at": "2026-04-17 06:00:00",
      "value_score": 3.14
    }
  ],
  "total": 100,
  "attribution": "Data from artificialanalysis.ai"
}
```

**Fields:**
- `intelligence`, `coding`, `math` — benchmark scores (higher is better)
- `price_blended` — $/M tokens (blended input/output)
- `output_speed` — tokens per second
- `ttft` — time to first token in milliseconds
- `value_score` — intelligence / price_blended (higher = better value)

---

### GET /api/signals/openrouter

OpenRouter model catalog with pricing.

**Auth:** plus or pro required

**Response:**
```json
{
  "tier": "plus",
  "models": [
    {
      "model_id": "anthropic/claude-opus-4.7",
      "model_name": "Claude Opus 4.7",
      "provider_name": "Anthropic",
      "price_input": 15.0,
      "price_output": 75.0,
      "context_length": 200000,
      "max_completion": 32000,
      "modality": "text",
      "is_free": false,
      "first_seen": "2026-04-15",
      "fetched_at": "2026-04-17 06:00:00"
    }
  ],
  "total": 500,
  "total_free": 120
}
```

---

### GET /api/signals/huggingface

Hugging Face trending models.

**Auth:** plus or pro required

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `sort` | string | `trending` | Sort order: `trending`, `downloads`, `likes`, `newest`, `params` |

**Response:**
```json
{
  "tier": "plus",
  "sort": "trending",
  "models": [
    {
      "model_id": "Qwen/Qwen3.6-35B-A3B",
      "model_name": "Qwen3.6-35B-A3B",
      "author": "Qwen",
      "pipeline_tag": "text-generation",
      "library_name": "transformers",
      "trending_score": 95.5,
      "downloads_30d": 1500000,
      "downloads_all": 5000000,
      "likes": 2500,
      "params": 35000000000,
      "created_at": "2026-04-10",
      "url": "https://huggingface.co/Qwen/Qwen3.6-35B-A3B",
      "fetched_at": "2026-04-17 06:00:00"
    }
  ],
  "total": 200,
  "pipeline_tags": [
    { "pipeline_tag": "text-generation", "cnt": 85 },
    { "pipeline_tag": "text-to-image", "cnt": 30 }
  ],
  "top_authors": [
    { "author": "meta-llama", "cnt": 12, "total_downloads": 50000000 }
  ]
}
```

---

### GET /api/signals/ai-status

AI provider operational status, uptime, and incidents.

**Auth:** plus or pro required

**Response:**
```json
{
  "tier": "plus",
  "providers": [
    {
      "provider": "anthropic",
      "name": "Anthropic",
      "status_url": "https://status.anthropic.com",
      "status": "operational",
      "components": [{ "name": "API", "status": "operational" }],
      "uptime_30d": 99.85,
      "total_checks_30d": 720,
      "fetched_at": "2026-04-17 09:45:00"
    }
  ],
  "incidents": [
    {
      "provider": "openai",
      "provider_name": "OpenAI",
      "incident_id": "inc123",
      "title": "Elevated API error rates",
      "status": "resolved",
      "impact": "minor",
      "started_at": "2026-04-15 14:00:00",
      "resolved_at": "2026-04-15 15:30:00",
      "url": "https://status.openai.com/incidents/inc123"
    }
  ],
  "history": {
    "anthropic": [
      { "status": "operational", "fetched_at": "2026-04-17 09:45:00" }
    ]
  },
  "total_incidents": 3
}
```

---

### GET /api/signals/stocks/history

Historical OHLCV stock data for tracked AI/tech tickers.

**Auth:** plus or pro required

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `ticker` | string | — | Filter to a single ticker (e.g. `NVDA`). Omit for all tickers. |

**Response:**
```json
{
  "tier": "plus",
  "history_days": 90,
  "tickers": {
    "NVDA": [
      { "date": "2026-01-15", "open": 130.0, "high": 135.0, "low": 129.5, "close": 134.0, "volume": 45000000 },
      { "date": "2026-01-16", "open": 134.0, "high": 136.0, "low": 133.0, "close": 135.5, "volume": 38000000 }
    ],
    "MSFT": [...]
  }
}
```

---

### GET /api/signals/github/history

GitHub repository tracking history — stars, forks, issues over time.

**Auth:** plus or pro required

**Response:**
```json
{
  "tier": "plus",
  "history_days": 90,
  "repos": {
    "anthropics/claude-code": [
      { "date": "2026-04-15 06:00:00", "stars": 44500, "forks": 3100, "open_issues": 120 },
      { "date": "2026-04-16 06:00:00", "stars": 44650, "forks": 3150, "open_issues": 118 }
    ]
  }
}
```

---

### GET /api/signals/downloads/history

Daily download counts for tracked npm and PyPI packages.

**Auth:** plus or pro required

**Response:**
```json
{
  "tier": "plus",
  "history_days": 90,
  "packages": {
    "langchain:pypi": [
      { "date": "2026-04-15 12:00:00", "downloads": 850000 },
      { "date": "2026-04-16 12:00:00", "downloads": 920000 }
    ]
  }
}
```

---

### GET /api/signals/people

Tracked AI/tech voices with their latest blog posts.

**Auth:** optional (free gets limited list)

**Response:**
```json
{
  "tier": "free",
  "people": {
    "items": [
      {
        "slug": "simon-willison",
        "name": "Simon Willison",
        "role": "Creator of Datasette, Django co-creator",
        "avatarUrl": "https://...",
        "latestPost": {
          "title": "Qwen beats Opus",
          "url": "https://simonwillison.net/2026/Apr/16/qwen-beats-opus/",
          "date": "2026-04-16"
        }
      }
    ],
    "total_available": 15
  }
}
```

---

### GET /api/signals/person

Single person detail — bio, links, and their RSS posts.

**Parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | string | yes | Person identifier (e.g. `simon-willison`) |

**Auth:** optional (free gets 5 posts, plus/pro get 100)

**Response:**
```json
{
  "tier": "free",
  "person": {
    "slug": "simon-willison",
    "name": "Simon Willison",
    "bio": "Creator of Datasette...",
    "role": "Creator of Datasette, Django co-creator",
    "avatarUrl": "https://...",
    "links": { "blog": "https://simonwillison.net", "twitter": "https://twitter.com/simonw" },
    "posts": {
      "items": [
        { "title": "Qwen beats Opus", "url": "https://...", "summary": "...", "date": "2026-04-16", "categories": "ai" }
      ],
      "total_available": 250
    }
  }
}
```

---

### GET /api/signals/company

Single company view — stock data, blog posts, GitHub repos.

**Parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `slug` | string | yes | Company identifier (e.g. `anthropic`, `nvidia`, `microsoft`) |

**Auth:** optional (tier affects data depth)

**Response:**
```json
{
  "tier": "free",
  "company": {
    "slug": "anthropic",
    "name": "Anthropic",
    "description": "AI safety company, creator of Claude",
    "category": "frontier-models",
    "ticker": null,
    "stock": null,
    "posts": {
      "items": [{ "title": "...", "url": "...", "date": "..." }],
      "total_available": 50
    },
    "repos": [
      { "repo": "anthropics/claude-code", "stars": 45000, "forks": 3200, "category": "agents" }
    ]
  }
}
```

For companies with a stock ticker, `stock` includes: `price`, `change_pct`, `ytd_pct`, `high_52w`, `low_52w`, `sparkline`.

---

### GET /api/signals/feed

Aggregated RSS feed items and clippings from tracked sources, with entity cross-links.

**Auth:** optional (tier affects limits)

**Response:**
```json
{
  "tier": "free",
  "rss": {
    "items": [
      { "feed_name": "Simon Willison", "url": "https://...", "title": "...", "summary": "...", "date": "...", "categories": "..." }
    ],
    "total_available": 500
  },
  "clippings": {
    "items": [
      { "title": "...", "excerpt": "...", "source_url": "...", "tags": "...", "published_at": "..." }
    ],
    "total_available": 100
  },
  "entity_links": {
    "feed_to_person": { "Simon Willison": "simon-willison" },
    "feed_to_company": { "Anthropic Blog": "anthropic" }
  }
}
```

---

## Search

### GET /api/search

Hybrid keyword + semantic search across all posts and editorial signals.

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `q` | string | — | **Required.** Search query (natural language or keywords) |
| `mode` | string | `hybrid` | Search mode: `keyword`, `semantic`, or `hybrid` |
| `type` | string | — | Filter by content type: `post` or `signal`. Omit for both. |
| `limit` | number | 20 | Max results (max 50) |

**Search modes:**

- **`keyword`** — FTS5 full-text search. Fast, exact matching, supports phrases (`"smiling curve"`), prefix matching (`infra*`), and boolean logic.
- **`semantic`** — Vector similarity via Workers AI embeddings + Vectorize. Understands meaning — "companies building AI infrastructure" finds relevant results even without exact keyword matches.
- **`hybrid`** (default) — Runs both keyword and semantic in parallel. Results found by both methods are score-boosted. Best overall quality.

**Response:**
```json
{
  "query": "open weights commoditising frontier",
  "mode": "hybrid",
  "results": [
    {
      "type": "signal",
      "id": "c80f7950...",
      "title": "Open Weight Models Have Crossed the Watershed",
      "excerpt": "LangChain published a post titled...",
      "tags": "[\"open-weight\",\"watershed\"]",
      "published_at": "2026-04-08 21:18:39",
      "score": 0.723,
      "match_source": "semantic"
    },
    {
      "type": "post",
      "id": "abc123",
      "slug": "weekly-wrap-up",
      "title": "NOOPS Weekly — Week of 14 April",
      "excerpt": "The harness hierarchy gets its formal paper...",
      "author": "both",
      "category": "essay",
      "tier_access": "free,plus,pro",
      "score": 0.65,
      "match_source": "both"
    }
  ],
  "total": 15
}
```

**Result fields:**
- `match_source`: `keyword` (found by FTS5), `semantic` (found by vector search), or `both` (found by both — highest confidence)
- `score`: relevance score (higher is better, not comparable across modes)
- `type`: `post` or `signal`
- `slug`: present on posts — use with `/api/posts/:slug` to fetch full content

**Agent tips:**
- Use `mode=hybrid` (default) for best results on natural language queries
- Use `mode=keyword` for exact phrase/term searches
- After finding results, fetch full content with `/api/posts/:slug?format=md`

---

## Thesis scoreboard

The NOOPS theses scored against the market since publication. The scoreboard is the analytical spine of the site.

### GET /api/scoreboard

Public JSON of all theses with verdicts.

**Response:**
```json
{
  "theses": [
    {
      "slug": "saas-apocalypse",
      "title": "The SaaS Apocalypse",
      "direction": "bearish",
      "verdict": "tracking",
      "basketPct": 7.4,
      "relPp": -5.7,
      "publishedAt": "2026-04-05",
      "updatedAt": "2026-07-18",
      "version": 42,
      "qualitative": false
    }
  ],
  "as_of": "2026-07-22"
}
```

**Verdict semantics:** scored on the basket's performance **relative to the S&P 500** since the thesis was published — the call, not the tape. A bearish thesis over an underperforming basket reads `tracking`. `watch` = instrument panel, no directional call. `qualitative` theses have no market basket.

### Thesis and research pages (HTML)

- `GET /theses` — scoreboard index (public)
- `GET /thesis/{slug}` — thesis page. Public: statement, verdict, basket board, recent evidence. **Pro/admin** (session or personal API key): full wiki analysis, complete version history, full evidence archive.
- `GET /research/{category}/{name}` — knowledge-graph pages (entities, concepts, source-summaries). Pro/admin.

Agents wanting structured thesis content should prefer the **wiki API** (`https://wiki.noops.au/api` — same bearer key, read-only): `GET /api/pages?category=thesis`, `GET /api/pages/{slug}?format=md`, full-text `?q=` search, `/history`, `/changelog`.

---

## Post management (write endpoints)

These endpoints require either a bearer token (`Authorization: Bearer <EDITORIAL_API_KEY>`) or an admin session cookie.

### POST /api/posts/ingest

Create a new post. Accepts JSON or markdown with YAML frontmatter.

**Auth:** Bearer token or admin session

**JSON format:**
```json
{
  "title": "My Post Title",
  "slug": "my-post-title",
  "body_md": "# Heading\n\nMarkdown content...",
  "subtitle": "Optional subtitle",
  "author": "john",
  "tags": ["ai", "infrastructure"],
  "thesis_ids": ["thesis-1"],
  "category": "essay",
  "tier_access": "free,plus,pro",
  "excerpt": "Optional custom excerpt",
  "status": "draft"
}
```

**Required fields:** `title`, `slug`, `body_md`

**Defaults:**
| Field | Default |
|-------|---------|
| `status` | `draft` |
| `author` | `both` |
| `category` | `analysis` |
| `tier_access` | `free` |
| `excerpt` | Auto-generated from body (first 200 chars) |

**Markdown frontmatter format:**

Set `Content-Type: text/markdown` or `text/plain` and send:

```
---
title: My Post Title
slug: my-post-title
author: john
tags: [ai, infrastructure]
category: essay
tier_access: free,plus,pro
status: draft
---

# Heading

Markdown content here...
```

**Slug validation:** Must be lowercase alphanumeric with hyphens (e.g. `my-post-title`). Must be unique.

**Response (201):**
```json
{
  "ok": true,
  "id": "a1b2c3d4...",
  "slug": "my-post-title",
  "url": "/posts/my-post-title"
}
```

**Notes:**
- Markdown is rendered to HTML server-side using GFM (GitHub Flavored Markdown)
- Links in rendered HTML open in new tabs (`target="_blank"`)
- If `status` is `published`, the post is immediately indexed into Vectorize for semantic search

---

### PATCH /api/posts/[id]

Update an existing post by ID. Send only the fields you want to change.

**Auth:** Bearer token or admin session

**Request:**
```json
{
  "title": "Updated Title",
  "body_md": "Updated markdown...",
  "status": "published",
  "tags": ["ai", "updated-tag"]
}
```

**Updatable fields:** `title`, `subtitle`, `slug`, `body_md`, `author`, `tags`, `thesis_ids`, `category`, `tier_access`, `status`, `featured_image`, `excerpt`

**Status transitions:**
- Setting `status` to `published` automatically sets `published_at` (if not already set)
- When a published post is updated, it is re-indexed in Vectorize for semantic search

**Response:**
```json
{ "ok": true }
```

**Errors:**
| Status | Meaning |
|--------|---------|
| 400 | No fields to update, or invalid slug format |
| 401 | Unauthorized |
| 404 | Post not found |
| 409 | Slug already exists (on another post) |

---

### DELETE /api/posts/[id]

Permanently delete a post by ID.

**Auth:** Admin session only (bearer token not accepted)

**Response:**
```json
{ "ok": true, "deleted": "a1b2c3d4..." }
```

---

### POST /api/posts/[id]/email

Email a published post to all members, respecting tier access (paid members see full content, free members see excerpt with upgrade CTA).

**Auth:** Admin session only

**Validation:**
- Post must exist and have `status: published`
- Post must not have been emailed already (`emailed_at` must be null)

**Response:**
```json
{ "ok": true, "sent_to": 142 }
```

---

## Newsletters (write endpoints)

### POST /api/newsletters/ingest

Create a newsletter draft. Accepts JSON or markdown with YAML frontmatter.

**Auth:** Bearer token (`EDITORIAL_API_KEY`) or admin session

**Markdown format** (set `Content-Type: text/markdown`):

```
---
title: NOOPS Daily Signals — 22 July 2026
type: daily
author: both
tags: [ai, infrastructure]
tier_access: plus,pro
create_post: true
post_slug: signals-2026-07-22
---

Markdown body here (NOOPS-flavoured — see below)...
```

**Required:** `title`, `type` (`daily` | `weekly`), body.

**Defaults:** `tier_access` is `plus,pro` for daily, `free,plus,pro` for weekly.

**`create_post: true`** also creates a companion draft post from the same content (at `post_slug`, auto-generated from the title if omitted) — signals deep-links in the email can point at it.

**Response (201):** `{ ok, id, ...post info if created }`. Newsletters are created as **drafts** — review and send from `/admin` (Newsletters tab). Sending is always a human action.

---

## NOOPS-flavoured Markdown (for newsletters and posts)

All markdown rendered by the site (posts, newsletters, pages, templates) supports standard GFM **plus a Pandoc-adjacent attribute syntax** for styling — write markdown, never inline HTML/CSS:

| Syntax | Renders as |
|--------|-----------|
| `[▼ CRWD 5.7%]{.mv .down}` | `<span class="mv down">…</span>` — red mover chip |
| `[▲ ASML 2.9%]{.mv .up}` | green mover chip |
| `## The board {.eyebrow}` | mono uppercase eyebrow heading |
| `## Title {#some-id}` | heading with anchor id |
| `[See dashboard →](/dashboard){.morelink}` | styled call-to-action link |
| `::: {.board}` … `:::` | bordered container block (nestable; markdown inside renders normally) |

**Class vocabulary:** `board`, `eyebrow`, `mv` (+ `up`/`down`), `facts`, `morelink`, `chip`. Styles are applied automatically — as CSS on the web, inlined styles in email. Do not write inline HTML for styling.

**Canonical daily-board example:**

```
::: {.board}
## The board {.eyebrow}

[▼ CRWD 5.7%]{.mv .down} [▲ ASML 2.9%]{.mv .up}

::: {.facts}
- **Models** — Gemini 3.6 Flash: same intelligence, half the time-per-task.
- **Status** — OpenAI degraded for part of the day.
:::

[See the full dashboard →](https://noops.au/dashboard){.morelink}
:::
```

**Security:** only `.class` and `#id` tokens are honoured inside `{…}`; anything else (style=, on*=, key=value) renders as literal text.

---

## Image upload

### POST /api/images/upload

Upload an image to R2 storage.

**Auth:** Bearer token or admin session

**Content-Type:** `multipart/form-data`

**Form fields:**
| Field | Required | Description |
|-------|----------|-------------|
| `file` | yes | Image file |
| `path` | no | Custom storage path (e.g. `posts/my-article/hero.jpg`) |

**Allowed types:** JPEG, PNG, GIF, WebP, SVG, AVIF
**Max size:** 10 MB

**Auto-generated path:** If no `path` is provided, files are stored as `YYYY/MM/sanitized-filename.ext`.

**Response:**
```json
{
  "ok": true,
  "key": "2026/05/my-image.webp",
  "url": "https://noops.au/images/2026/05/my-image.webp",
  "markdown": "![my-image.webp](https://noops.au/images/2026/05/my-image.webp)",
  "size": 245000,
  "type": "image/webp"
}
```

**Agent tip:** Use the returned `url` value in the `featured_image` field when creating or updating posts.

### Attaching a bespoke PDF to a post

Posts can offer a downloadable PDF (the footnoted/annotated artifact — produced separately, not auto-generated). Upload it with the post's slug as the path:

```bash
curl -X POST https://noops.au/api/images/upload \
  -H "Authorization: Bearer $EDITORIAL_API_KEY" \
  -F "file=@report.pdf" \
  -F "path=pdfs/<post-slug>.pdf"
```

It serves at `https://noops.au/pdfs/<post-slug>.pdf`, and the post page's **Download PDF** button appears automatically once the file exists (and disappears if it's deleted). Max 10MB.

### GET /api/images/upload

List uploaded images.

**Auth:** Admin session only

**Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `prefix` | string | — | Filter by path prefix |
| `limit` | number | 50 | Max results (max 200) |

**Response:**
```json
{
  "images": [
    { "key": "2026/05/hero.webp", "url": "https://noops.au/images/2026/05/hero.webp", "size": 245000, "uploaded": "2026-05-18T10:00:00.000Z" }
  ],
  "truncated": false
}
```

---

## Admin / Backup

### POST /api/admin/backup

Create a full database backup as a SQL dump stored in R2.

**Auth:** Bearer token or admin session

**Response:**
```json
{
  "ok": true,
  "filename": "backups/noops-2026-05-19T03-00-00.sql",
  "size_bytes": 125000,
  "size_human": "122.1 KB",
  "tables": {
    "members": 150,
    "sessions": 420,
    "magic_links": 30,
    "subscriptions": 85,
    "posts": 42,
    "editorial_signals": 156,
    "newsletters": 12,
    "newsletter_recipients": 1800
  },
  "old_backups_deleted": 0,
  "backup_count": 15
}
```

**Notes:**
- Backs up 8 core tables: members, sessions, magic_links, subscriptions, posts, editorial_signals, newsletters, newsletter_recipients
- Automatically deletes backups older than the most recent 30
- Also runs automatically via cron at 3:00 AM AEST (17:00 UTC) daily

### GET /api/admin/backup

List existing backups.

**Auth:** Bearer token or admin session

**Response:**
```json
{
  "backups": [
    {
      "filename": "backups/noops-2026-05-19T03-00-00.sql",
      "size_bytes": 125000,
      "size_human": "122.1 KB",
      "uploaded": "2026-05-19T17:00:00.000Z"
    }
  ],
  "total": 15
}
```

---

## Pagination

All list endpoints support `limit` and `offset`:

```
GET /api/posts/feed?limit=10&offset=0    # First page
GET /api/posts/feed?limit=10&offset=10   # Second page
```

- Default `limit`: 20
- Maximum `limit`: 50
- Response includes `total` for calculating pages

---

## Cross-referencing

The `/api/signals/overview` response includes an `entity_links` object for navigating between data types:

- `ticker_to_company` — map stock ticker to company slug (use with `/api/signals/company?slug=...`)
- `feed_to_person` — map RSS feed name to person slug (use with `/api/signals/person?slug=...`)
- `feed_to_company` — map RSS feed name to company slug

---

## Rate limits

Standard Cloudflare rate limits apply (~1000 requests/minute). There are no additional API-specific rate limits. Be considerate with automated polling — the data updates at most every few hours for most signal types.

**Cache behaviour:**
- `/api` directory: cached 1 hour
- `/rss` feed: cached 15 minutes
- Signal data: varies by source (check `data_freshness` in `/api/signals/overview`)

---

## About NOOPS

NOOPS is a strategic research advisory co-founded by **John Allsopp** and **Mark Pesce**, based in Sydney, Australia. It tracks signals, patterns, and structural shifts as AI reshapes how organisations operate.

**Coverage areas:** AI model benchmarks, AI/tech stocks, GitHub repository metrics, Hugging Face model trends, OpenRouter model pricing, AI provider operational status, npm/PyPI download tracking, editorial analysis.

**Contact:** hello@noops.au
**Terms:** https://noops.au/terms.html
**Not financial advice.** See terms for full disclaimer.

## POST /api/members/grant-pro

Programmatic Pro provisioning for trusted registration systems (e.g. AI Investor
bundled memberships). Auth: `Bearer PROVISIONING_API_KEY` (or editorial key).
Body: `{ email, name?, cohort, months? (default 12, max 24) }`. Creates or
upgrades the member to Pro; stacks months on an unexpired comp; never touches a
paying Pro (no expiry). Idempotent per (email, cohort) — replays return the
original grant with `duplicate: true`. Audit trail in `pro_grants`.
Full integration guide: scripts/grant-pro-SKILL.md.

## POST /api/members/provision

Free-tier sibling of grant-pro for opt-in flows (waitlist checkbox → free NOOPS
account). Same auth (`Bearer PROVISIONING_API_KEY`). Body: `{ email, name?,
cohort }`. Creates a free member if new; existing members of any tier are left
untouched (`action: "unchanged"`). Idempotent per (email, cohort). Server-to-
server only — signup deliberately has no CORS.
