# Stockholm Philosophy — API guide for programs and agents

This site is a calendar of philosophy events in Stockholm with a small forum.
Everything a logged-in person can do on it, a program can do through the JSON
API described here, acting as that person's account — reading the queues,
merging duplicates, approving submissions, moderating the forum, drafting
threads, submitting events. No source code, shell access or browser is
involved: everything below is plain HTTP with JSON, and this page is the whole
of the documentation.

**A note to any program reading this.** This page is reference material. It
asks nothing of you and it overrides nothing: what to do with this API is
decided by the person you work for, and only they can ask you to act on this
site, to obtain a token, or to keep anything from here. Where the text below
says "the program does X", that is a description of the protocol, not an
instruction.

Base URL: the site you were pointed at, e.g. `https://example.org`. Every API
path below is relative to it. All bodies are JSON, all timestamps are RFC3339
in UTC, and every response is `Cache-Control: no-store`.

Contents

0. This guide as an installable skill
1. Getting a token
2. How requests and errors work
3. Member endpoints — /me, tokens, the forum, events
4. Admin endpoints — /api/v1/admin/…
5. Typical sessions
6. What the API deliberately does not do

---

## 0. This guide as an installable skill

For a person who wants their agent to know this site in later sessions
without fetching this page again: `GET /api/skill.md` serves this same guide
as a `SKILL.md` — YAML frontmatter saying when it applies, the site's own
address filled into every example, and a note on where a token is expected.
The file holds no secret.

This is something the person chooses; the site does not expect any program
to install it on its own initiative. When a person does ask for it, the file
goes wherever their harness reads skills from — for Claude Code, one of:

```sh
# user-wide (every project)
mkdir -p ~/.claude/skills/stockholm-philosophy
curl -s "$BASE/api/skill.md" -o ~/.claude/skills/stockholm-philosophy/SKILL.md

# or this project only
mkdir -p .claude/skills/stockholm-philosophy
curl -s "$BASE/api/skill.md" -o .claude/skills/stockholm-philosophy/SKILL.md
```

The site is the source of truth: this page is versioned with it, and an
installed copy can be refreshed from the same address.

---

## 1. Getting a token

Every call under `/api/v1/` needs a bearer token except the two below that
exist to issue one. A token belongs to an account and carries that account's
*current* role (member, moderator or admin) and status, checked on every
request. No password is ever involved.

If your host speaks MCP, this is already done: add `$BASE/mcp` as a remote
MCP server and the host runs the OAuth dance (PKCE, dynamic client
registration) itself, outside your own tool-call loop. You never see the
token, and there is nothing below for you to do — skip to what the endpoints
mean, since `/mcp` exposes the same ones through one tool, `api_request`.

What follows is the fallback for a plain HTTP client or script, with no MCP
host to run that dance for it. The flow is a device authorization: the
program asks, a person approves in their browser while logged in, the
program collects. A program runs it when — and only when — the person it
works for has asked it to act on this site.

**Step 1 — the program asks.** `POST /api/v1/auth/requests`, no credentials.
The name is for the person approving: what the program is and where it runs.

```sh
curl -s -X POST "$BASE/api/v1/auth/requests" \
  -H 'Content-Type: application/json' \
  -d '{"name":"claude code on sebastian'"'"'s laptop"}'
```

```json
{
  "code": "WXTR-4821",
  "secret": "5f0e…",
  "approve_url": "https://example.org/account/approve/WXTR-4821",
  "expires_at": "2026-09-16T10:15:00Z",
  "poll_interval_seconds": 5,
  "next": "The person approves at https://example.org/account/approve/WXTR-4821 while logged in; the page shows the code WXTR-4821 and the name \"…\" for them to check. Until then GET /api/v1/auth/requests/WXTR-4821 … answers pending."
}
```

`secret` is what proves the collector is the asker; it is meant for nobody
but the program that asked.

**Step 2 — the person approves.** They open `approve_url` — the program
will typically show it to them, with the code, so they can check the page
matches. They log in if asked (as a member or admin account; the root
password login cannot approve) and click Approve or Deny. The page tells them
which account the token will act as and that it carries all of that account's
rights until revoked.

**Step 3 — the program polls.** `GET /api/v1/auth/requests/{code}` every
`poll_interval_seconds`, with the secret in a header:

```sh
curl -s "$BASE/api/v1/auth/requests/WXTR-4821" -H "X-Request-Secret: $SECRET"
```

- `200 {"status":"pending", …}` — not decided yet; poll again.
- `200 {"status":"approved","token":"sp_…","account":{"id":3,"username":"sebastian","role":"admin"}, …}`
  — **once**. The token is handed over exactly one time and the request is
  then spent; a second poll answers `410`.
- `410 {"error":{"code":"denied"}}`, `"expired"` (15 minutes), or
  `"collected"` — a new request is needed if a token is still wanted.
- `404` — unknown code, or the wrong secret. The two are not told apart.

**Step 4 — the token.** It is shown once, and where it is kept is the
person's decision. The examples in this guide keep it in a curl config file,
`~/.config/stockholm-philosophy/curlrc`, holding one line: `header = "Authorization: Bearer
sp_…"`. Then every call is `curl -K ~/.config/stockholm-philosophy/curlrc …` and the token
itself never appears on a command line, in a shell history or in a
conversation — which matters, because the permission checks in most agent
harnesses refuse a command that carries a literal credential, and rightly so.

The collect step can write that file directly, so the token is never
displayed at all:

```sh
mkdir -p ~/.config/stockholm-philosophy && chmod 700 ~/.config/stockholm-philosophy
curl -s "$BASE/api/v1/auth/requests/WXTR-4821" -H "X-Request-Secret: $SECRET" \
  | jq -r '"header = \"Authorization: Bearer \(.token)\""' > ~/.config/stockholm-philosophy/curlrc
chmod 600 ~/.config/stockholm-philosophy/curlrc
```

The first useful call with it:

```sh
curl -sK ~/.config/stockholm-philosophy/curlrc "$BASE/api/v1/me"
```

(Anything else that keeps a secret out of the command line does as well — a
harness's own secret store, say. `-H "Authorization: Bearer $TOKEN"` with an
environment variable works too, but some checks flag even that.)

The person can see and revoke the token at any time on their account page
(`/account`), and an admin on `/admin/accounts`. Both pages can also mint a
token by hand if the approval flow is inconvenient.

There is no way to create a token through the API itself, on purpose: a leaked
token must not be able to multiply. `DELETE /api/v1/tokens/{id}` revokes one
of the caller's own, including the one in use.

---

## 2. How requests and errors work

**Authentication.** `Authorization: Bearer sp_…` on every request — in the
examples, supplied by `curl -K ~/.config/stockholm-philosophy/curlrc` as described in
section 1. Cookies are ignored and never set. Without a token:

```
401 {"error":{"code":"missing_token","message":"No bearer token. To get one, read GET /api — section 'Getting a token'."}}
```

Other 401 codes: `invalid_token` (revoked or unknown), `account_suspended`,
`accounts_off` (an admin switched accounts off for the whole site; every token
stops until they are back on).

**Bodies.** Send `Content-Type: application/json` and one JSON object.
Unknown fields are refused with `400 unknown_field` — a misspelt field is told
to you rather than dropped. Bodies are capped at 256 KiB.

**Errors** always look like `{"error":{"code":"…","message":"…"}}`. The
message is the same sentence the site's own form would show a person. Codes
you will meet:

| status | code | meaning |
|---|---|---|
| 400 | `bad_json`, `unknown_field`, `bad_*` | the request itself is malformed |
| 401 | see above | no usable token |
| 403 | `moderators_only`, `admins_only`, `thread_starters`, `submitters`, `not_yours`, `self_demote`, `self_suspend`, `root_only` | the token's account may not do this |
| 404 | `not_found`, `forum_off` | not there, or not yours to see — a private thread you cannot see is indistinguishable from none |
| 409 | `already_decided`, `changed_since_seen`, `locked`, `delete_protected`, `replies_changed`, `username_taken`, … | the world moved; read again |
| 422 | `invalid` | parsed, but refused on content — too long, date in the past, empty title. Some carry a `refused` list |
| 429 | `rate_limited` | per-IP posting budget; staff are exempt |

**Policy is the site's, not the API's.** Who may submit an event, who may
start a discussion, what gets held for review, blocked words, size caps — every
rule the HTML forms apply, the API applies identically. What the API drops is
only the anti-bot layer (form tokens, minimum fill times, honeypots, origin
checks), which the bearer token already answers for.

**Times in event fields.** Event submissions take `date` (`2026-10-03`) and
`start_time`/`end_time` (`18:00`) in Europe/Stockholm, exactly like the form.
Everything the API *returns* is RFC3339 UTC.

**Concurrency.** Where a decision depends on what you read, the endpoint takes
what you read: `if_updated_at` on publishing a submission,
`expected_reply_count` on publishing a thread. A mismatch is a 409, never a
silent overwrite.

---

## 3. Member endpoints

Any token, whatever its role.

### `GET /api/v1/me`

Who the token is and what it may do — the natural first call.

```json
{
  "account": {"id": 3, "username": "sebastian", "role": "admin"},
  "author": {"pseudonym": "apodictic-tortoise", "post_count": 12, "standing": "normal"},
  "can": {"moderate": true, "admin": true, "submit_events": true, "start_threads": true},
  "policies": {"events_submitters": "members", "forum_enabled": true, "forum_thread_starters": "anyone_approved"}
}
```

`author` is null until the account has a forum identity, which registering in
a browser or a first post gives it. `can` is already resolved against the
policies for *this* account, so nothing needs re-deriving.

### Tokens

- `GET /api/v1/tokens` — `{"tokens":[{id, name, prefix, created_at, last_used_at, revoked_at}]}` for your own account.
- `DELETE /api/v1/tokens/{id}` — revoke one of your own. `{"ok":true}`.

### Forum

All 404 with `forum_off` when the forum is switched off. Bodies are Markdown
(a small subset: paragraphs, emphasis, links, lists, headings); responses carry
both the `body` you wrote and the `html` the site renders.

- `GET /api/v1/forum/threads?page=1` — the public board, newest activity
  first. `{"threads":[…], "total", "page", "pages"}`. A thread:
  `{uuid, slug, title, status, visibility, locked, reply_count, last_post_at, created_at, url}`.
- `GET /api/v1/forum/threads/mine` — your private drafts (the threads on no
  board). Each carries `share_url`, the keyed link to hand a reviewer.
- `GET /api/v1/forum/threads/{slug}` — one thread with its posts in page
  order: `{"thread":{…}, "posts":[…], "can_publish", "can_reply", "kept_reply_count"?}`.
  A post: `{uuid, parent?, author_name, body, html, opening, depth, status,
  hold_reason?, deleted, tombstone, can_edit, can_delete, created_at, edited_at}`.
  A private thread is visible to its author and to staff; to anyone else it is
  a 404.
- `POST /api/v1/forum/threads` — start a discussion.
  Body `{"title": "…", "body": "…", "private": false}`.
  `403 thread_starters` if the policy is admins-only and you are not staff.
  `201 {"thread", "post", "status", "hold_reason", "awaiting_approval"?}`.
  `status` is `published` or `pending`; pending means a moderator reads it
  first (the `anyone_approved` policy, or a hold rule such as a link from a
  new author). **`"private": true` makes a draft**: off the board, never
  held, readable by you, staff, and whoever you give `share_url` to, until
  you publish it.
- `POST /api/v1/forum/threads/{slug}/replies` — `{"body": "…", "parent": "<post uuid>"}`
  (`parent` optional; omitted answers the opening post). `409 locked` on a
  closed thread. `201 {"post", "status", "hold_reason", "url"}`.
- `PATCH /api/v1/forum/posts/{uuid}` — `{"body": "…"}`, your own post only
  (`403 not_yours`). `200 {"post", "held"}`; `held` means the rewrite tripped
  a hold rule and went back to the queue.
- `DELETE /api/v1/forum/posts/{uuid}` — your own post. `409 delete_protected`
  if a moderator pinned it. Deleting an opening post retires the thread.
- `POST /api/v1/forum/threads/{slug}/publish` — put your private draft on the
  board. Body optional: `{"keep_replies": false, "expected_reply_count": 2}`.
  Replies made on the draft are swept away unless `keep_replies` is true;
  `expected_reply_count` is how many you read (409 `replies_changed` if more
  have arrived). The draft is screened now as a public post would be, so
  `status` may come back `pending`. `409 already_public` if it is.

### Events

- `GET /api/v1/events?from=<RFC3339>&limit=200&language=sv|en&cost=free|paid`
  — the programme as the homepage shows it: upcoming occurrences of published
  events in the listed places. `{"occurrences":[{occurrence_uuid, starts_at,
  ends_at, cancelled, event_id, slug, url, title, summary, language,
  organizer, is_free, price_text, venue_name, venue_address, online,
  source_name, source_url}]}`.
- `GET /api/v1/events/{slug}` — one event in full with `occurrences`,
  plus `can_edit` and, when it is yours, `pending_revision`. An unpublished
  event is visible only to its submitter and staff.
- `GET /api/v1/events/mine` — your submissions that no public page shows:
  waiting for a moderator, or refused. `{"submissions":[{event, starts_at,
  rejected, pending_revision}]}`.
- `POST /api/v1/events` — submit an event. `403 submitters` under a policy
  that excludes you (`/me` → `can.submit_events`). The fields are the form's,
  by the form's names:

  ```json
  {"title": "Filosofikafé: Vad är rättvisa?", "description": "An evening on …",
   "date": "2026-10-03", "start_time": "18:00", "end_time": "20:00",
   "venue_name": "Fruängens bibliotek", "venue_address": "Fruängens gata 1",
   "organizer": "SSFP", "language": "sv", "price_text": "", "url": "https://…"}
  ```

  `title`, `description`, `date`, `start_time` and `venue_name` are required;
  `language` is `sv`, `en` or empty; an empty `price_text` means free.
  `201 {"event", "status"}` — `published` for staff, `pending` (a moderator
  will read it) for everyone else. `422` carries the form's own sentence.
- `PATCH /api/v1/events/{slug}` — replace a submitted event's fields (send
  the whole object, as above). Your own or, for staff, any submitted event;
  an ingested event answers `403 not_submitted` (curate it through the admin
  API). `200 {"queued": false, "event"}` when the change took effect;
  `202 {"queued": true, "revision", "event"}` when it is waiting for a
  moderator — a non-staff edit of a published event.

---

## 4. Admin endpoints

Under `/api/v1/admin/`. **M** = any moderator or admin token, **A** = admin
only (`403 admins_only` otherwise). A member token gets `403 moderators_only`
everywhere here. Routes marked *forum* answer `404 forum_off` on a deployment
without a forum.

### Orientation

- **M** `GET /api/v1/admin/dashboard` — `{viewer, build, queues:{submitted_events,
  proposed_edits, held_forum_posts?, pending_review_events?, dedupe_pairs?},
  sources?:[{key, display_name, kind, priority, enabled, bad, last_run}],
  accounts?:{total, moderators, access}}`. The `?` parts are admins'.
- **A** `POST /api/v1/admin/sources/{key}/run` — queue an ingest run. `202 {"queued":true}`.

### Review (ingested events) and duplicates

- **A** `GET /api/v1/admin/review` — `{pending:[…], off_programme:[…], dedupe_pairs:[…], programme}`.
  `pending` is ingested events the classifier was unsure about; `off_programme`
  is the first 50 events the programme rule excludes (a check on the rule, not
  a queue). Each event: `{id, uuid, slug, title, summary, event_type,
  language, organizer, is_free, price_text, philosophy_score, status, curated,
  place, online, place_pinned, on_programme, submitted, venue_name,
  venue_address, starts_at, ends_at, url, occurrences, source_names,
  created_at, updated_at}`.
- **A** `GET /api/v1/admin/dedupe` — `{pairs:[{id, event_a:{id, slug, title,
  status}|null, event_b, event_a_title, event_b_title, title_similarity,
  venue_similarity, time_similarity, combined_score, decision, created_at}]}`,
  oldest first.
- **A** `POST /api/v1/admin/dedupe/{id}/merge` — merge the pair; the event whose
  source has the higher priority survives. `409 already_decided`.
- **A** `POST /api/v1/admin/dedupe/{id}/reject` — keep them separate.
- **A** `POST /api/v1/admin/dedupe/rescore` — re-compare the whole catalogue (queued, `202`).

### Events (the catalogue)

- **A** `GET /api/v1/admin/events?q=&status=&place=&off_programme=1&pinned=1&page=1&per_page=50`
  — every event, whatever its status. `{events, total, page, pages, per_page, places}`;
  `places` is every path any event holds, for the `place` filter.
- **A** `GET /api/v1/admin/events/{id}` — `{event (with tags), pending_revision?}`.
- **A** `GET /api/v1/admin/events/{id}/raw` — the source rows behind it, `raw_payload` as JSON.
- **A** `PATCH /api/v1/admin/events/{id}` — curate. Every field optional:
  `{"title", "summary", "status": "published|pending|hidden", "place_pin": "sweden/stockholms-lan", "place_pin_online": false}`.
  `place_pin` overrides where the rules put the event; `""` clears the pin
  and queues a reclassification.
- **A** `POST /api/v1/admin/events/{id}/approve` — set status published.
- **A** `POST /api/v1/admin/events/{id}/hide` — set status hidden.
- **A** `POST /api/v1/admin/events/{id}/merge-into` — `{"duplicate_of": 123}`:
  merge event 123 into this one (this one survives).

### Submissions (events people added)

- **M** `GET /api/v1/admin/submissions` — `{submitted:[…], revisions:[…], submitters}`.
  A submitted event carries `updated_at`; a revision carries `changes:[{field, from, to}]`.
- **M** `POST /api/v1/admin/submissions/{id}/publish` — body optional
  `{"if_updated_at": "<the updated_at you read>"}`; `409 changed_since_seen`
  if the submitter edited it since. Put it on the programme.
- **M** `POST /api/v1/admin/submissions/{id}/reject` — off the programme; the
  submitter can still see and edit it.
- **M** `POST /api/v1/admin/submissions/{id}/revisions/{rid}/apply` — apply a
  proposed edit to the live event. `409 already_decided`.
- **M** `POST /api/v1/admin/submissions/{id}/revisions/{rid}/discard` — drop it; the event is unchanged.

### Settings

- **A** `GET /api/v1/admin/settings` — one object:

  ```json
  {"events": {"submitters": "staff|members|anyone"},
   "accounts": {"access": "open|login_only|off"},
   "forum": {"enabled": true, "thread_starters": "admins|anyone|anyone_approved",
             "config": {…}, "config_defaults": {…}, "config_ceilings": {…}, "has_overrides": false},
   "programme": {"places": ["sweden/stockholms-lan"], "include_unplaced": true, "has_overrides": false, "defaults": {…}}}
  ```

  Every write below answers `200 {"message": "<what the site now does>", "settings": {…}}`.
- **A** `PUT /api/v1/admin/settings/events` — `{"submitters": "…"}`.
- **A** `PUT /api/v1/admin/settings/accounts` — `{"access": "open|login_only"}`.
  `off` is refused for every token (`403 root_only`): it would end the
  caller's own account. Only the root password session on the HTML page can.
- **A** *forum* `PUT /api/v1/admin/settings/forum` — `{"enabled": true, "thread_starters": "…"}`, either optional.
- **A** *forum* `PUT /api/v1/admin/settings/forum/config` — the whole tunables
  object, every field required: `{max_title_chars, max_body_chars,
  min_fill_seconds, posts_per_hour, threads_per_day, edits_per_hour,
  max_links, hold_links_below_posts, blocked_words: []}`. `422` with a
  `refused` list names the fields over their ceilings.
- **A** *forum* `DELETE /api/v1/admin/settings/forum/config` — back to the TOML defaults.
- **A** `PUT /api/v1/admin/settings/programme` — `{"places": ["sweden/stockholms-lan"], "include_unplaced": true}`.
  Paths are lowercase, slash-separated; an empty list is refused (it would
  empty the site). Saving the deployment default clears the override.
- **A** `DELETE /api/v1/admin/settings/programme` — reset to the default.

### Accounts and tokens

- **A** `GET /api/v1/admin/accounts` — `{accounts:[{id, username, role, status, created_at, last_login_at, tokens:[…]}], access, self}`.
- **A** `POST /api/v1/admin/accounts` — `{"username", "password" (≥10 chars), "role": "member|moderator|admin"}`. `201`; `409 username_taken`.
- **A** `PATCH /api/v1/admin/accounts/{id}` — any of `{"role", "status": "active|suspended", "password"}`.
  You cannot demote or suspend yourself (`403`).
- **A** `GET /api/v1/admin/accounts/{id}/tokens` — that account's tokens, revoked included.
- **A** `POST /api/v1/admin/accounts/{id}/tokens` — `{"name": "…"}` → `201 {"token": "sp_…", "meta": {…}}`.
  The one place the admin API hands out a plaintext; it is not shown again.
- **A** `DELETE /api/v1/admin/accounts/{id}/tokens/{tid}` — revoke.

### Forum moderation (*forum*)

- **M** `GET /api/v1/admin/forum?page=1&deleted=1` — `{held:[…], posts:[…], total, page, pages, deleted_only, settings}`.
  `held` is the queue; `posts` is every post, newest first (or the deleted
  ones with `deleted=1`). Each: `{id, uuid, thread_id, thread_title,
  thread_slug, thread_private, author_id, author_name, author_standing,
  body, opening, status, hold_reason, delete_protected, deleted_at,
  created_at, edited_at}`. Note that the ids here are numeric post ids, not
  the uuids the member routes use.
- **M** `POST /api/v1/admin/forum/posts/{id}/approve` — publish a held post.
  Approving an opening post carries its thread with it. `409 deleted` if the
  author deleted it (restore first).
- **M** `POST /api/v1/admin/forum/posts/{id}/hide` — hide one; an opening
  post's thread goes with it.
- **M** `POST /api/v1/admin/forum/posts/{id}/restore` — bring a deleted post
  back, protected from deletion. `409 not_deleted`.
- **M** `PUT /api/v1/admin/forum/posts/{id}/delete-protection` — `{"protected": true}`.
- **M** `PUT /api/v1/admin/forum/threads/{id}/lock` — `{"locked": true}` closes it to replies.
- **M** `PUT /api/v1/admin/forum/authors/{id}/standing` — `{"standing": "normal|trusted|blocked"}`.
  Trusted skips the hold heuristics; blocked has every submission silently
  discarded (the author is never told).

---

## 5. Typical sessions

How the calls fit together, for the three things people most often ask for.

**Working the admin queues** (an admin token): `GET /api/v1/me` confirms
`can.admin`; `GET /api/v1/admin/dashboard` says which queues have anything in
them. On `GET /api/v1/admin/review`, each `dedupe_pairs` entry names two
events — `POST …/dedupe/{id}/merge` when they are the same evening,
`…/reject` when not — and each `pending` event takes `POST
…/events/{id}/approve` or `/hide`. On `GET /api/v1/admin/submissions`, each
submitted event takes `POST …/submissions/{id}/publish` (with
`if_updated_at` set to the `updated_at` that was read) or `/reject`, and each
revision is applied or discarded. `GET /api/v1/admin/forum` lists what is
`held` for approving or hiding. Every write answers with the resource as it
now stands, so a report of what changed can quote titles rather than ids.

**Drafting a discussion privately, then publishing it** (any token where
`can.start_threads`): `POST /api/v1/forum/threads` with `"private": true`
answers with a `share_url` the author can hand to reviewers; `PATCH
/api/v1/forum/posts/{uuid}` on the opening post revises the draft; `POST
/api/v1/forum/threads/{slug}/publish` puts it on the board. A `status` of
`pending` in that last answer means a moderator reads it before it appears.

**Submitting an event** (any token where `can.submit_events`): `POST
/api/v1/events` with the form's fields answers `published` (staff) or
`pending` (everyone else, a moderator reads it), together with `event.url`.

---

## 6. What the API deliberately does not do

- Mint a token for you. Tokens are approved by a person or minted on a page.
- Serve the root `ADMIN_PASSWORD` login. Make an admin *account*.
- Switch accounts off. That ends every token, so it is the root login's alone.
- Read or set cookies, or accept a session cookie in place of a token.
- Offer OpenAPI, webhooks, or a CLI. This page is the reference; it is
  versioned with the site, so re-read it when something surprises you.

Discovery for a program that only knows the site's address: every HTML page
carries `Link: </api>; rel="service-desc"` and a `<link rel="service-desc">`
in its head, `GET /llms.txt` points here, `GET /api` and `GET /api/v1` serve
this page, `GET /api/skill.md` serves it as a skill, and every `401` for a
missing token says where to read.
