# LoreKit REST API

> Persistent memory for AI coding agents. This is the Markdown edition of the REST reference, written for AI agents and scripts. It is generated from the same OpenAPI document as the interactive reference, so the two always agree.

- **Base URL:** `https://pqokxlhvnosogizsjztg.supabase.co/functions/v1` — every path below is relative to it.
- **OpenAPI 3.1 JSON:** https://lorekit-p6eb7t86a-mads-thines-projects.vercel.app/api-docs/spec
- **Interactive reference (browser):** https://lorekit-p6eb7t86a-mads-thines-projects.vercel.app/api-docs
- **MCP tools, scopes, filtering and limits:** https://lorekit-p6eb7t86a-mads-thines-projects.vercel.app/llms.txt
- **Version:** 1.0.0 · 42 operations

## Before you call

1. **Authenticate.** Send `Authorization: Bearer <token>` on every request except the ones marked public. LoreKit API token (lk_rw_*, lk_ro_*, lk_wo_*) or Supabase JWT. Every route — memories and orgs alike — accepts either; access is gated by the token read/write permission, not by auth tier.
2. **Writes are real by default.** An operation that lists `X-LoreKit-Dry-Run` under its common headers changes data unless the request carries `X-LoreKit-Dry-Run: true`, which validates and authorizes it without changing anything. Operations that do not list it (including the `POST` reads) ignore it. See [Common headers](#common-headers).
3. **Errors.** A non-2xx response carries [Error](#error). `429` means rate-limited; wait for the `Retry-After` header before retrying.
4. **JSON in, JSON out.** Send request bodies with `Content-Type: application/json`.

## Common headers

| Header | Accepted on | Type | Notes |
|---|---|---|---|
| `X-LoreKit-Client` | 40 operations (GET, POST, DELETE, PATCH) | string | Which surface is calling. One of `dashboard`, `cli`, `mcp`, `api`; anything else is recorded as unattributed. Purely for usage analytics — it never changes the response. Optional: an absent header records this transport's own default (`api`), so send it only to identify a MORE SPECIFIC calling surface (e.g. `cli`). Reads attributed to `dashboard` are excluded from `GET /memories/read-activity`. |
| `X-LoreKit-Dry-Run` | 15 operations (POST, DELETE, PATCH) | boolean | Safe-explore mode. `true` validates and authorizes the request but makes NO changes. Absent or `false` executes it for real — the server default is `false`; only this reference's try-it console pre-fills `true`. Default `false`. |

## Endpoints

### Memories

| Method | Path | Summary |
|---|---|---|
| GET | [`/memories`](#get-memories) | List memories |
| POST | [`/memories`](#post-memories) | Create or update a memory |
| DELETE | [`/memories`](#delete-memories) | Archive (or, with force=true, hard-delete) a memory by scope+key; ?org=<slug> targets org-owned lore |
| POST | [`/memories/list`](#post-memorieslist) | List memories (filters in a JSON body) |
| POST | [`/memories/search`](#post-memoriessearch) | Search memories with OR+AND filtering |
| POST | [`/memories/restore`](#post-memoriesrestore) | Restore an archived memory by scope+key |
| POST | [`/memories/purge`](#post-memoriespurge) | Hard-delete archived memories older than retention_days |
| POST | [`/memories/purge-expired`](#post-memoriespurge-expired) | Hard-delete memories whose TTL has elapsed |
| GET | [`/memories/scopes`](#get-memoriesscopes) | List every visible scope with its active memory count |
| GET | [`/memories/quota`](#get-memoriesquota) | Your active memories against your plan cap, and how many you can still write |
| GET | [`/memories/tags`](#get-memoriestags) | List every visible label with how many memories carry it |
| GET | [`/memories/facets`](#get-memoriesfacets) | List every filterable value (label, agent, trigger, repo, branch, pull request) with its memory count |
| POST | [`/memories/facets`](#post-memoriesfacets) | List every filterable value with its memory count (filters in a JSON body) |
| GET | [`/memories/pivot`](#get-memoriespivot) | Cross-tabulate two filterable dimensions and count the memories in each cell |
| POST | [`/memories/pivot`](#post-memoriespivot) | Cross-tabulate two dimensions (filters in a JSON body) |
| GET | [`/memories/activity`](#get-memoriesactivity) | Memories created per UTC hour/day per scope over a window |
| POST | [`/memories/activity`](#post-memoriesactivity) | Memories created per UTC hour/day per scope over a window (filters in a JSON body) |
| GET | [`/memories/read-activity`](#get-memoriesread-activity) | Memory records read per UTC hour/day over a window |
| GET | [`/memories/read-ranking`](#get-memoriesread-ranking) | Memories ranked by how often they have actually been read (hot or cold lore) |
| GET | [`/memories/utility`](#get-memoriesutility) | Every lesson placed on the delivered × chosen grid, plus what delivery costs |
| GET | [`/memories/clusters`](#get-memoriesclusters) | Groups of near-duplicate memories, ranked as merge candidates (read-only) |
| GET | [`/memories/relevant`](#get-memoriesrelevant) | Top-K lessons ranked for a query — the shortlist, not the whole match set |
| GET | [`/memories/usage`](#get-memoriesusage) | Aggregate usage statistics for your own activity (reads, writes, outcomes, per scope-type) over an optional period |
| GET | [`/memories/usage/runs`](#get-memoriesusageruns) | Enumerate runs (correlation_id values) with what each one read, wrote, and touched |
| GET | [`/memories/{id}`](#get-memoriesid) | Get memory by ID |
| PATCH | [`/memories/{id}`](#patch-memoriesid) | Update a memory |
| DELETE | [`/memories/{id}`](#delete-memoriesid) | Archive a memory (soft-delete), or hard-delete it with force=true |
| POST | [`/memories/read`](#post-memoriesread) | Batch-read memories by scope::key reference |
| POST | [`/memories/{id}/restore`](#post-memoriesidrestore) | Restore an archived memory by ID |

### Orgs

| Method | Path | Summary |
|---|---|---|
| GET | [`/orgs`](#get-orgs) | List my organizations |
| POST | [`/orgs`](#post-orgs) | Create an organization |
| GET | [`/orgs/{slug}`](#get-orgsslug) | Get organization |
| PATCH | [`/orgs/{slug}`](#patch-orgsslug) | Rename organization |
| DELETE | [`/orgs/{slug}`](#delete-orgsslug) | Delete organization — owner only |

### Members

| Method | Path | Summary |
|---|---|---|
| GET | [`/orgs/{slug}/members`](#get-orgsslugmembers) | List members |
| PATCH | [`/orgs/{slug}/members/{userId}`](#patch-orgsslugmembersuserid) | Update member role |
| DELETE | [`/orgs/{slug}/members/{userId}`](#delete-orgsslugmembersuserid) | Remove member |

### Invites

| Method | Path | Summary |
|---|---|---|
| GET | [`/orgs/{slug}/invites`](#get-orgssluginvites) | List pending invites |
| POST | [`/orgs/{slug}/invites`](#post-orgssluginvites) | Send an invite |
| DELETE | [`/orgs/{slug}/invites/{inviteId}`](#delete-orgssluginvitesinviteid) | Revoke an invite |

### Blog

| Method | Path | Summary |
|---|---|---|
| GET | [`/blog/likes`](#get-bloglikes) | Get a blog post's like total (public) |
| POST | [`/blog/likes`](#post-bloglikes) | Add a like to a blog post (public) |

## Memories

### GET /memories

**List memories.**

Lists live memories by default. Filters AND across dimensions and OR within one, so `?kind=lesson&host=reviewer` reads "reviewer's lessons".

`expiring_within_days=N` narrows to lore whose TTL runs out soon — `expires_at` strictly after now and at or before now + N days. The lower bound is exclusive because that is what "still live" means everywhere else in the API (an already-expired memory is never returned); the upper bound is inclusive, so "within 7 days" includes one expiring exactly 7 days out. Memories with no TTL are never in the result. It is a RELATIVE horizon rather than an absolute timestamp so a saved or shared link keeps answering the same question tomorrow.

An invalid `scope` is a `400`, not a silently ignored filter — a scope filter is the question being asked, so an ungrammatical one is rejected rather than matched against nothing and reported as an empty page. The value is validated, never normalised: the write path stores `scope` verbatim, so the filter matches exactly what was written.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `scope` | query | no | string | Non-empty. |
| `key` | query | no | string | Length 1–512. |
| `key_prefix` | query | no | string | Length 1–512. |
| `tags` | query | no | string | — |
| `tags_mode` | query | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | query | no | string | — |
| `q` | query | no | string | Length 1–512. |
| `created_since` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `source_agent` | query | no | string | Length 1–2048. |
| `source_agent_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | query | no | string | Length 1–2048. |
| `trigger_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | query | no | string | Length 1–2048. |
| `kind_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `host` | query | no | string | Length 1–2048. |
| `host_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | query | no | string | Length 1–2048. |
| `origin_repo_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | query | no | string | Length 1–2048. |
| `origin_branch_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | query | no | string | Length 1–2048. |
| `origin_pr_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | query | no | string | Length 1–2048. |
| `owner_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `sort` | query | no | string | One of `updated_at`, `created_at`, `opened_count`, `last_opened_at`. Default `updated_at`. |
| `archived` | query | no | string | One of `true`, `false`. Default `false`. |
| `expiring_within_days` | query | no | integer | Range 1–365. |
| `min_age_days` | query | no | integer | Range 1–3650. |
| `unseen_days` | query | no | integer | Range 1–3650. |
| `max_seen_count` | query | no | integer or null | Range 0–100000. |
| `max_read_count` | query | no | integer or null | Range 0–100000. |
| `max_opened_count` | query | no | integer or null | Range 0–100000. |
| `opened_within_days` | query | no | integer | Range 1–3650. |
| `min_seen_count` | query | no | integer | Range 2–100000. |
| `min_read_count` | query | no | integer | Range 1–100000. |
| `min_opened_count` | query | no | integer | Range 1–100000. |
| `limit` | query | no | integer | Range 1–100. Default `50`. |
| `cursor` | query | no | string | — |

**Responses**

- `200` — Paginated memories → [MemoryPage](#memorypage)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories

**Create or update a memory.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `scope` | yes | string | Non-empty. |
| `key` | yes | string | Length 1–512. |
| `value` | yes | string | Length ≤ 65536. |
| `tags` | no | array of string | — |
| `source_agent` | no | string | — |
| `trigger` | no | string | — |
| `created_at` | no | string | — |
| `org` | no | string | — |
| `ttl_days` | no | integer | Range 1–365. |
| `clear_ttl` | no | boolean | Default `false`. |
| `kind` | no | string | One of `lesson`, `bus`, `signal`. |
| `host` | no | string | Length ≤ 64. |
| `origin_repo` | no | string | — |
| `origin_branch` | no | string | — |
| `origin_commit` | no | string | — |
| `origin_pr` | no | number or string | — |
| `cited` | no | array of string | Each item: Length ≤ 1024. |

**Responses**

- `201` — Created → [Memory](#memory)
- `400`, `401`, `429` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"<scope>","key":"<key>","value":"<value>"}'
```

### DELETE /memories

**Archive (or, with force=true, hard-delete) a memory by scope+key; ?org=<slug> targets org-owned lore.**

An invalid `scope` is a `400`, not a `404` — a bad scope is bad input, not a missing memory.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `scope` | query | no | string | Non-empty. |
| `key` | query | no | string | Length 1–512. |
| `force` | query | no | string | One of `true`, `false`. Default `false`. |
| `org` | query | no | string | Length 1–50. |

**Responses**

- `204` — Archived or deleted. No body.
- `400`, `401`, `403`, `404` — Error → [Error](#error)

**Example**

```bash
curl -X DELETE "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories/list

**List memories (filters in a JSON body).**

The same read as `GET /memories`.

The BODY form of the read above, for callers whose filters do not fit a URL. Every dimension is a real array (`{"host": ["reviewer", "aw"], "host_mode": "in"}`) bounded at 1000 values of 512 characters each, where the query form caps a dimension at 2048 characters and splits every value on a comma. Both transports decode to ONE normalised filter shape server-side, so they return the same rows and report under the same usage tool name — this is a transport choice and nothing else. The body is optional: a bodiless request is the unfiltered read.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `scope` | no | string | Non-empty. |
| `key` | no | string | Length 1–512. |
| `key_prefix` | no | string | Length 1–512. |
| `q` | no | string | Length 1–512. |
| `created_since` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `tags_mode` | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `trigger_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `kind_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `host` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `host_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_repo_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_branch_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_pr_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `owner_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `sort` | no | string | One of `updated_at`, `created_at`, `opened_count`, `last_opened_at`. Default `updated_at`. |
| `archived` | no | boolean | Default `false`. |
| `expiring_within_days` | no | integer | Range 1–365. |
| `min_age_days` | no | integer | Range 1–3650. |
| `unseen_days` | no | integer | Range 1–3650. |
| `max_seen_count` | no | integer | Range 0–100000. |
| `max_read_count` | no | integer | Range 0–100000. |
| `max_opened_count` | no | integer | Range 0–100000. |
| `opened_within_days` | no | integer | Range 1–3650. |
| `min_seen_count` | no | integer | Range 2–100000. |
| `min_read_count` | no | integer | Range 1–100000. |
| `min_opened_count` | no | integer | Range 1–100000. |
| `limit` | no | integer | Range 1–100. Default `50`. |
| `cursor` | no | string | — |

**Responses**

- `200` — Paginated memories → [MemoryPage](#memorypage)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/list" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### POST /memories/search

**Search memories with OR+AND filtering.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `q` | no | string | — |
| `scopes` | no | array of string | Each item: Non-empty. |
| `tags` | no | array of string | — |
| `filter` | no | object | Recursive filter tree. Either a condition `{ field, op, value }` or a group `{ and: [...] }` / `{ or: [...] }`, nestable to any depth. Operators: is, is_not, contains, does_not_contain, starts_with, ends_with, is_set, is_not_set. |
| `limit` | no | integer | Range 1–100. Default `50`. |
| `cursor` | no | string | — |

**Responses**

- `200` — Search results → [MemoryPage](#memorypage)
- `400`, `401` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/search" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### POST /memories/restore

**Restore an archived memory by scope+key.**

An invalid `scope` is a `400`, not a `404` — a bad scope is bad input, not a missing memory.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `scope` | yes | string | Non-empty. |
| `key` | yes | string | Length 1–512. |

**Responses**

- `200` — Restored → [RestoreResponse](#restoreresponse)
- `400`, `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/restore" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"<scope>","key":"<key>"}'
```

### POST /memories/purge

**Hard-delete archived memories older than retention_days.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `retention_days` | no | integer | Range 1–365. Default `30`. |

**Responses**

- `200` — Number of memories hard-deleted → [PurgeResponse](#purgeresponse)
- `400`, `401`, `403`, `429` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/purge" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### POST /memories/purge-expired

**Hard-delete memories whose TTL has elapsed.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Responses**

- `200` — Number of memories hard-deleted → [PurgeResponse](#purgeresponse)
- `401`, `403`, `429` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/purge-expired" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/scopes

**List every visible scope with its active memory count.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Responses**

- `200` — Scopes → [ScopesResponse](#scopesresponse)
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/scopes" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/quota

**Your active memories against your plan cap, and how many you can still write.**

`personal` is the pair the memory cap enforces: personal memories against the personal limit, with `remaining` writes left before `POST /memories` is refused with `memory_cap`. Org-owned memories count against each org's own cap, so they are reported as `org_count` and are not subtracted from `remaining`. A token narrowed to `personal` or to selected orgs gets `org_count` and `total` over only the orgs it reaches (`0` under `personal`). Requires a user-scoped credential; a service-role token has no user and gets 403.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Responses**

- `200` — Quota → [MemoryQuotaResponse](#memoryquotaresponse)
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/quota" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/tags

**List every visible label with how many memories carry it.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `archived` | query | no | string | One of `true`, `false`. Default `false`. |

**Responses**

- `200` — Labels → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `tags` | yes | array of object | — |
  | `tags[].tag` | yes | string | — |
  | `tags[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/tags" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/facets

**List every filterable value (label, agent, trigger, repo, branch, pull request) with its memory count.**

An invalid `scope` is a `400`, not a silently ignored filter — the menu counts and the list they drill into agree on what a scope is. The `created_since`/`created_until` window and the five retention thresholds (`min_age_days`, `unseen_days`, `max_seen_count`, `max_read_count`, `max_opened_count`) and their four reverse directions (`opened_within_days`, `min_seen_count`, `min_read_count`, `min_opened_count`) narrow the counted population exactly as they do on `GET /memories`, so a count matches the list under the same parameters. They emit no facet rows of their own — they are comparisons, not enumerable dimensions. `q`, `key` and `expiring_within_days` remain unmirrored, so with those active a count is an upper bound.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `archived` | query | no | string | One of `true`, `false`. Default `false`. |
| `facets` | query | no | string | — |
| `scope` | query | no | string | Non-empty. |
| `tags` | query | no | string | — |
| `tags_mode` | query | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | query | no | string | — |
| `source_agent` | query | no | string | Length 1–2048. |
| `source_agent_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | query | no | string | Length 1–2048. |
| `trigger_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | query | no | string | Length 1–2048. |
| `kind_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `host` | query | no | string | Length 1–2048. |
| `host_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | query | no | string | Length 1–2048. |
| `origin_repo_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | query | no | string | Length 1–2048. |
| `origin_branch_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | query | no | string | Length 1–2048. |
| `origin_pr_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | query | no | string | Length 1–2048. |
| `owner_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `created_since` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `min_age_days` | query | no | integer | Range 1–3650. |
| `unseen_days` | query | no | integer | Range 1–3650. |
| `max_seen_count` | query | no | integer or null | Range 0–100000. |
| `max_read_count` | query | no | integer or null | Range 0–100000. |
| `max_opened_count` | query | no | integer or null | Range 0–100000. |
| `opened_within_days` | query | no | integer | Range 1–3650. |
| `min_seen_count` | query | no | integer | Range 2–100000. |
| `min_read_count` | query | no | integer | Range 1–100000. |
| `min_opened_count` | query | no | integer | Range 1–100000. |

**Responses**

- `200` — Facet values → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `facets` | yes | array of object | — |
  | `facets[].facet` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `facets[].value` | yes | string | — |
  | `facets[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/facets" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories/facets

**List every filterable value with its memory count (filters in a JSON body).**

The same drill-down catalog as `GET /memories/facets`. `facets` is an array of the closed facet vocabulary rather than a comma list, so an unknown name is a `400` here where the query form tolerated one and narrowed to nothing.

The BODY form of the read above, for callers whose filters do not fit a URL. Every dimension is a real array (`{"host": ["reviewer", "aw"], "host_mode": "in"}`) bounded at 1000 values of 512 characters each, where the query form caps a dimension at 2048 characters and splits every value on a comma. Both transports decode to ONE normalised filter shape server-side, so they return the same rows and report under the same usage tool name — this is a transport choice and nothing else. The body is optional: a bodiless request is the unfiltered read.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `archived` | no | boolean | Default `false`. |
| `facets` | no | array of string | Each item: One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
| `scope` | no | string | Non-empty. |
| `created_since` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `tags_mode` | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `trigger_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `kind_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `host` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `host_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_repo_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_branch_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_pr_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `owner_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `min_age_days` | no | integer | Range 1–3650. |
| `unseen_days` | no | integer | Range 1–3650. |
| `max_seen_count` | no | integer | Range 0–100000. |
| `max_read_count` | no | integer | Range 0–100000. |
| `max_opened_count` | no | integer | Range 0–100000. |
| `opened_within_days` | no | integer | Range 1–3650. |
| `min_seen_count` | no | integer | Range 2–100000. |
| `min_read_count` | no | integer | Range 1–100000. |
| `min_opened_count` | no | integer | Range 1–100000. |

**Responses**

- `200` — Facet values → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `facets` | yes | array of object | — |
  | `facets[].facet` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `facets[].value` | yes | string | — |
  | `facets[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/facets" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### GET /memories/pivot

**Cross-tabulate two filterable dimensions and count the memories in each cell.**

`GET /memories/facets` with a second group-by. `row` and `col` name any two of the facet dimensions (they may be the same, giving that dimension’s diagonal). **Both axes are self-excluded from the filters**, so a caller that turns a cell into `row in [x] AND col in [y]` and asks again still gets every other cell back — that is what keeps a drilled-in grid navigable instead of collapsing to the clicked cell. A pair counting zero emits no cell. `truncated` reports whether `limit` cut the grid. The `created_since`/`created_until` window, the five retention thresholds and their four reverse directions narrow the counted population, matching `GET /memories/facets`.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `row` | query | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
| `col` | query | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
| `archived` | query | no | string | One of `true`, `false`. Default `false`. |
| `limit` | query | no | string | Pattern: `^\d+$`. |
| `scope` | query | no | string | Non-empty. |
| `tags` | query | no | string | — |
| `tags_mode` | query | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | query | no | string | — |
| `source_agent` | query | no | string | Length 1–2048. |
| `source_agent_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | query | no | string | Length 1–2048. |
| `trigger_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | query | no | string | Length 1–2048. |
| `kind_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `host` | query | no | string | Length 1–2048. |
| `host_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | query | no | string | Length 1–2048. |
| `origin_repo_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | query | no | string | Length 1–2048. |
| `origin_branch_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | query | no | string | Length 1–2048. |
| `origin_pr_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | query | no | string | Length 1–2048. |
| `owner_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `created_since` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `min_age_days` | query | no | integer | Range 1–3650. |
| `unseen_days` | query | no | integer | Range 1–3650. |
| `max_seen_count` | query | no | integer or null | Range 0–100000. |
| `max_read_count` | query | no | integer or null | Range 0–100000. |
| `max_opened_count` | query | no | integer or null | Range 0–100000. |
| `opened_within_days` | query | no | integer | Range 1–3650. |
| `min_seen_count` | query | no | integer | Range 2–100000. |
| `min_read_count` | query | no | integer | Range 1–100000. |
| `min_opened_count` | query | no | integer | Range 1–100000. |

**Responses**

- `200` — Pivot cells → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `row` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `col` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `cells` | yes | array of object | — |
  | `cells[].row` | yes | string | — |
  | `cells[].col` | yes | string | — |
  | `cells[].count` | yes | integer | ≥ 0. |
  | `truncated` | yes | boolean | — |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/pivot?row=<row>&col=<col>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories/pivot

**Cross-tabulate two dimensions (filters in a JSON body).**

The same cross-tabulation as `GET /memories/pivot`.

The BODY form of the read above, for callers whose filters do not fit a URL. Every dimension is a real array (`{"host": ["reviewer", "aw"], "host_mode": "in"}`) bounded at 1000 values of 512 characters each, where the query form caps a dimension at 2048 characters and splits every value on a comma. Both transports decode to ONE normalised filter shape server-side, so they return the same rows and report under the same usage tool name — this is a transport choice and nothing else. The body is optional: a bodiless request is the unfiltered read.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `row` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
| `col` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
| `archived` | no | boolean | Default `false`. |
| `limit` | no | integer | Range 1–2000. |
| `scope` | no | string | Non-empty. |
| `created_since` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `created_until` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `tags_mode` | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `trigger_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `kind_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `host` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `host_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_repo_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_branch_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_pr_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `owner_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `min_age_days` | no | integer | Range 1–3650. |
| `unseen_days` | no | integer | Range 1–3650. |
| `max_seen_count` | no | integer | Range 0–100000. |
| `max_read_count` | no | integer | Range 0–100000. |
| `max_opened_count` | no | integer | Range 0–100000. |
| `opened_within_days` | no | integer | Range 1–3650. |
| `min_seen_count` | no | integer | Range 2–100000. |
| `min_read_count` | no | integer | Range 1–100000. |
| `min_opened_count` | no | integer | Range 1–100000. |

**Responses**

- `200` — Pivot cells → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `row` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `col` | yes | string | One of `tag`, `source_agent`, `trigger`, `kind`, `host`, `origin_repo`, `origin_branch`, `origin_pr`, `owner`. |
  | `cells` | yes | array of object | — |
  | `cells[].row` | yes | string | — |
  | `cells[].col` | yes | string | — |
  | `cells[].count` | yes | integer | ≥ 0. |
  | `truncated` | yes | boolean | — |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/pivot" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"row":"tag","col":"tag"}'
```

### GET /memories/activity

**Memories created per UTC hour/day per scope over a window.**

An invalid `scope` is a `400`, not a silently ignored filter — the same rule the read counterpart `GET /memories/read-activity` follows. The five retention thresholds (`min_age_days`, `unseen_days`, `max_seen_count`, `max_read_count`, `max_opened_count`) and their four reverse directions (`opened_within_days`, `min_seen_count`, `min_read_count`, `min_opened_count`) narrow the counted population exactly as they do on `GET /memories`, so a series matches the list under the same parameters. There is no `created_since`/`created_until` pair here, unlike `/facets` and `/pivot`: this route’s own `since`/`until` already bound `created_at`.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `bucket` | query | no | string | One of `hour`, `day`. Default `day`. |
| `since` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `until` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `scope` | query | no | string | Non-empty. |
| `tags` | query | no | string | — |
| `tags_mode` | query | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | query | no | string | — |
| `source_agent` | query | no | string | Length 1–2048. |
| `source_agent_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | query | no | string | Length 1–2048. |
| `trigger_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | query | no | string | Length 1–2048. |
| `kind_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `host` | query | no | string | Length 1–2048. |
| `host_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | query | no | string | Length 1–2048. |
| `origin_repo_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | query | no | string | Length 1–2048. |
| `origin_branch_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | query | no | string | Length 1–2048. |
| `origin_pr_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | query | no | string | Length 1–2048. |
| `owner_mode` | query | no | string | One of `in`, `nin`. Default `in`. |
| `min_age_days` | query | no | integer | Range 1–3650. |
| `unseen_days` | query | no | integer | Range 1–3650. |
| `max_seen_count` | query | no | integer or null | Range 0–100000. |
| `max_read_count` | query | no | integer or null | Range 0–100000. |
| `max_opened_count` | query | no | integer or null | Range 0–100000. |
| `opened_within_days` | query | no | integer | Range 1–3650. |
| `min_seen_count` | query | no | integer | Range 2–100000. |
| `min_read_count` | query | no | integer | Range 1–100000. |
| `min_opened_count` | query | no | integer | Range 1–100000. |

**Responses**

- `200` — Activity buckets → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `bucket` | yes | string | One of `hour`, `day`. |
  | `since` | yes | string | — |
  | `until` | yes | string | — |
  | `buckets` | yes | array of object | — |
  | `buckets[].bucket` | yes | string | — |
  | `buckets[].scope` | yes | string | — |
  | `buckets[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/activity" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories/activity

**Memories created per UTC hour/day per scope over a window (filters in a JSON body).**

The same series as `GET /memories/activity`.

The BODY form of the read above, for callers whose filters do not fit a URL. Every dimension is a real array (`{"host": ["reviewer", "aw"], "host_mode": "in"}`) bounded at 1000 values of 512 characters each, where the query form caps a dimension at 2048 characters and splits every value on a comma. Both transports decode to ONE normalised filter shape server-side, so they return the same rows and report under the same usage tool name — this is a transport choice and nothing else. The body is optional: a bodiless request is the unfiltered read.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `bucket` | no | string | One of `hour`, `day`. Default `day`. |
| `since` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `until` | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `scope` | no | string | Non-empty. |
| `tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `tags_mode` | no | string | One of `any`, `all`, `none`. Default `any`. |
| `exclude_tags` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `source_agent_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `trigger` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `trigger_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `kind` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `kind_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `host` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `host_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_repo` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_repo_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_branch` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_branch_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `origin_pr` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `origin_pr_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `owner` | no | array of string | Items ≤ 1000. Each item: Length 1–512. |
| `owner_mode` | no | string | One of `in`, `nin`. Default `in`. |
| `min_age_days` | no | integer | Range 1–3650. |
| `unseen_days` | no | integer | Range 1–3650. |
| `max_seen_count` | no | integer | Range 0–100000. |
| `max_read_count` | no | integer | Range 0–100000. |
| `max_opened_count` | no | integer | Range 0–100000. |
| `opened_within_days` | no | integer | Range 1–3650. |
| `min_seen_count` | no | integer | Range 2–100000. |
| `min_read_count` | no | integer | Range 1–100000. |
| `min_opened_count` | no | integer | Range 1–100000. |

**Responses**

- `200` — Activity buckets → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `bucket` | yes | string | One of `hour`, `day`. |
  | `since` | yes | string | — |
  | `until` | yes | string | — |
  | `buckets` | yes | array of object | — |
  | `buckets[].bucket` | yes | string | — |
  | `buckets[].scope` | yes | string | — |
  | `buckets[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/activity" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### GET /memories/read-activity

**Memory records read per UTC hour/day over a window.**

Records read (not read *calls*) per bucket, summed over `memory.read` / `memory.list` / `memory.search` / `memory.list_archived`. Calls that identified themselves as the LoreKit dashboard (`X-LoreKit-Client: dashboard`) are EXCLUDED: browsing your own lore in the web UI is visualisation, not consumption, and would otherwise make this series grow every time you looked at it. `GET /memories/usage` still counts them — use it for the complete ledger.

Buckets are returned one per `(bucket, scope, read_kind)` cell, mirroring `GET /memories/activity`. `scope` is nullable: a read whose scope the server could not resolve (carried in a request body, or ungrammatical) is recorded as unattributed rather than dropped, so it still counts toward the unfiltered total. Pass the optional `scope` query parameter to restrict the series to one exact scope; because the metric is additive, those buckets SUM to the per-scope headline. That per-scope total can legitimately be SMALLER than the account total — the difference is the unattributable reads. An invalid `scope` is a `400`, not a silently ignored filter.

`read_kind` (migration 00080) splits retrieved from opened: `'targeted'` is `memory.read` (one exact scope+key — an agent deliberately opening a specific lesson); `'bulk'` is `memory.list`/`memory.search`/`memory.list_archived` (every row a listing call returned, e.g. a session-start hook injecting lessons). Retrieved + opened sum to the same total this endpoint always returned — the split refines the series, it does not change it.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `bucket` | query | no | string | One of `hour`, `day`. Default `day`. |
| `since` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `until` | query | no | string | Pattern: `^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?(Z\|[+-]\d{2}:?\d{2})?)?$`. |
| `scope` | query | no | string | Non-empty. |

**Responses**

- `200` — Read-activity buckets → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `bucket` | yes | string | One of `hour`, `day`. |
  | `since` | yes | string | — |
  | `until` | yes | string | — |
  | `buckets` | yes | array of object | — |
  | `buckets[].bucket` | yes | string | — |
  | `buckets[].scope` | yes | string or null | — |
  | `buckets[].read_kind` | no | string | One of `targeted`, `bulk`. |
  | `buckets[].count` | yes | integer | ≥ 0. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/read-activity" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/read-ranking

**Memories ranked by how often they have actually been read (hot or cold lore).**

Ranks memories by `read_count` (migration 00077) — how many times a `memory.read` / `memory.list` / `memory.search` / `memory.list_archived` call actually returned this exact row, not just how often the account read *something*. `direction=hot` (default) surfaces the most-consumed lore first; `direction=cold` surfaces the least, oldest-created first among ties — the prune-list input the `lorekit-groom` skill consumes.

`counting_since` is the date this counter started: a `cold` row with `read_count: 0` means "not read since that date", never "never read" — a memory written earlier may have been read plenty under the old, uncounted regime. Render the qualifier; never the bare word "never". `seen_count` (how many times the memory has been WRITTEN) rides along so a reader can compare consumption against recurrence in one response. Active memories only (archived/expired rows are excluded — they are already pruned). An invalid `scope` is a `400`, not a silently ignored filter.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `direction` | query | no | string | One of `hot`, `cold`. Default `hot`. |
| `scope` | query | no | string | Non-empty. |
| `limit` | query | no | integer | Range 1–100. Default `20`. |

**Responses**

- `200` — Ranked memories → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `direction` | yes | string | One of `hot`, `cold`. |
  | `counting_since` | yes | string | Format: date-time. |
  | `entries` | yes | array of object | — |
  | `entries[].id` | yes | string | Format: uuid. |
  | `entries[].scope` | yes | string | — |
  | `entries[].key` | yes | string | — |
  | `entries[].read_count` | yes | integer | ≥ 0. |
  | `entries[].last_read_at` | yes | string or null | Format: date-time. |
  | `entries[].seen_count` | no | integer | — |
  | `entries[].created_at` | yes | string | Format: date-time. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/read-ranking" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/utility

**Every lesson placed on the delivered × chosen grid, plus what delivery costs.**

Answers "is this lore earning its place?" by dividing rather than ranking. `read_count` counts every read including the bulk ride-alongs in a `memory.list` page (99.80% of them), so ranking by it — which `GET /memories/read-ranking` does — mostly ranks SCOPE BREADTH, and its cold end nominates narrow scopes for pruning instead of unused lore. Pull-through (`opened_count / read_count`, migration 00104) is a proper fraction, so the breadth appears in both halves and cancels.

Two axes give four quadrants plus a fifth state: **load-bearing** (delivered widely, chosen often — promote to a rule), **specialist** (narrow reach, high uptake — broaden its scope), **noise-tax** (injected constantly, never chosen — prune first), **dormant** (rarely offered, never taken — archive), and **unproven** (too young or too few deliveries to read a rate from). `unproven` is not a hedge: it is the state today's UI cannot express, where a fresh memory and a dead one look identical. The `thresholds` that draw the lines travel in every response so a client can caption them rather than restate them.

**Two windows, and both are reported.** `census` counts the LIFETIME counters on `memories` — the same two columns a per-lesson chip reads, so a card and the grid cannot disagree — while `cost` sums `memory_read_daily` over `[since, until)`, the only source that can be windowed at all. Caption each; do not present one as the other.

`cost.delivered_tokens` is ESTIMATED at four characters per token, not tokenized — the figure is an order of magnitude for a headline, and any surface rendering it must say so. `counting_since` is the date read counting started: a `0` means "not delivered since that date", never "never delivered". Passing `quadrant` additionally returns that quadrant's rows (`entries`); omitting it returns the counts alone. An invalid `scope` is a `400`.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `quadrant` | query | no | string | One of `load-bearing`, `specialist`, `noise-tax`, `dormant`, `unproven`. |
| `scope` | query | no | string | Non-empty. |
| `since` | query | no | string | Format: date-time. |
| `until` | query | no | string | Format: date-time. |
| `limit` | query | no | integer | Range 1–100. Default `20`. |

**Responses**

- `200` — Quadrant census, delivery cost, and the requested quadrant's rows → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `thresholds` | yes | object | — |
  | `thresholds.min_deliveries` | yes | integer | ≥ 0. |
  | `thresholds.min_age_days` | yes | integer | ≥ 0. |
  | `thresholds.chosen_pull_through` | yes | number | — |
  | `thresholds.broad_reach_deliveries` | yes | integer | ≥ 0. |
  | `counting_since` | yes | string | Format: date-time. |
  | `census` | yes | object | — |
  | `census.load-bearing` | yes | integer | ≥ 0. |
  | `census.specialist` | yes | integer | ≥ 0. |
  | `census.noise-tax` | yes | integer | ≥ 0. |
  | `census.dormant` | yes | integer | ≥ 0. |
  | `census.unproven` | yes | integer | ≥ 0. |
  | `cost` | yes | object | — |
  | `cost.delivered_reads` | yes | integer | ≥ 0. |
  | `cost.chosen_reads` | yes | integer | ≥ 0. |
  | `cost.delivered_tokens` | yes | integer | ≥ 0. |
  | `cost.chosen_tokens` | yes | integer | ≥ 0. |
  | `window` | yes | object | — |
  | `window.since` | yes | string or null | Format: date-time. |
  | `window.until` | yes | string or null | Format: date-time. |
  | `quadrant` | yes | string or null | One of `load-bearing`, `specialist`, `noise-tax`, `dormant`, `unproven`. |
  | `entries` | yes | array of object | — |
  | `entries[].id` | yes | string | Format: uuid. |
  | `entries[].scope` | yes | string | — |
  | `entries[].key` | yes | string | — |
  | `entries[].read_count` | yes | integer | ≥ 0. |
  | `entries[].opened_count` | yes | integer | ≥ 0. |
  | `entries[].last_opened_at` | yes | string or null | Format: date-time. |
  | `entries[].created_at` | yes | string | Format: date-time. |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/utility" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/clusters

**Groups of near-duplicate memories, ranked as merge candidates (read-only).**

Clusters the caller's recent lore by Jaccard similarity over tokenized bodies and returns each group with its member count, its similarity RANGE (`min_similarity`/`max_similarity`, measured over the pairs that LINKED the group, so both are `>= threshold`; clusters form transitively, so two members of one cluster may be less alike than `min_similarity` reads) and, when the member keys match a known recurrence class, that class plus whether the match is `pure` (every member matched, nothing else joined). Ordering is by score: summed `seen_count` first, then size, then similarity — "which redundancy has cost the most".

**Read-only is the contract, not a phase.** Deciding that N near-duplicate lessons are really one entry is a human judgment, so there is deliberately no merge counterpart and no parameter that makes this route act. It surfaces and ranks the evidence and stops.

**It answers a WINDOWED question.** Candidates are cut at `candidate_limit` in `updated_at desc` order *before* clustering, so `candidates === candidate_limit` means the answer is "what have I recently written that duplicates something else recent", not "what are all the duplicates in my store". `lorekit dedupe` streams the whole scope through the identical clustering core and is the answer to the second question.

Member bodies are never returned — only `hook`, the first line — for the same reason `GET /memories/relevant` returns none: the point is deciding which lessons to look at. An invalid `scope` is a `400`, not a silently ignored filter.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `scope` | query | no | string | Non-empty. |
| `threshold` | query | no | number | Range 0.5–1. Default `0.8`. |
| `min_seen_count` | query | no | integer or null | Range 0–1000. Default `3`. |
| `limit` | query | no | integer | Range 1–50. Default `20`. |

**Responses**

- `200` — Ranked duplicate clusters → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `threshold` | yes | number | — |
  | `candidates` | yes | integer | ≥ 0. |
  | `candidate_limit` | yes | integer | > 0. |
  | `clusters` | yes | array of object | — |
  | `clusters[].size` | yes | integer | ≥ 2. |
  | `clusters[].score` | yes | number | ≥ 0. |
  | `clusters[].min_similarity` | yes | number | Range 0–1. |
  | `clusters[].max_similarity` | yes | number | Range 0–1. |
  | `clusters[].recurrence_class` | yes | object or null | — |
  | `clusters[].recurrence_class.id` | yes | string | — |
  | `clusters[].recurrence_class.name` | yes | string | — |
  | `clusters[].recurrence_class.matched` | yes | array of string | — |
  | `clusters[].recurrence_class.pure` | yes | boolean | — |
  | `clusters[].members` | yes | array of object | — |
  | `clusters[].members[].scope` | yes | string | — |
  | `clusters[].members[].key` | yes | string | — |
  | `clusters[].members[].hook` | yes | string | — |
  | `clusters[].members[].seen_count` | yes | integer or null | ≥ 0. |
  | `clusters[].members[].updated_at` | yes | string or null | Format: date-time. |
  | `clusters[].members[].status` | yes | string or null | — |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/clusters" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/relevant

**Top-K lessons ranked for a query — the shortlist, not the whole match set.**

The one verb that RANKS. Every other read returns a single-signal ordering — `GET /memories` is `updated_at` desc, `POST /memories/search` is FTS rank — so a caller wanting a useful shortlist had to fetch a page and re-sort it, and every client that did so disagreed with the others. Here the score combines RECENCY (exponential decay, 14-day half-life), SALIENCE (`log1p(seen_count)` normalised across the candidates, so a lesson learned twelve times outranks one written once) and RELEVANCE (full-text match on `q`). The response is a compact index — scope, key, a one-line hook and the score — because the point is deciding WHICH few lessons deserve a reader's attention; fetch the bodies with `GET /memories/:id` or `memory.read`. `q` is optional: without it the ranking is recency + salience, which answers "what matters generally" rather than "what matters for this". `scopes` is ordered most-specific first and that order breaks ties, so a project lesson wins over the global one it ties with.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `q` | query | no | string | Length ≤ 1000. |
| `scopes` | query | no | string | — |
| `limit` | query | no | integer | Range 1–50. Default `10`. |
| `min_score` | query | no | number or null | Range 0–1. Default `0`. |

**Responses**

- `200` — Ranked lessons → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `entries` | yes | array of object | — |
  | `entries[].scope` | yes | string | Non-empty. |
  | `entries[].key` | yes | string | — |
  | `entries[].hook` | yes | string | — |
  | `entries[].score` | yes | number | — |
  | `entries[].factors` | yes | object | — |
  | `entries[].factors.recency` | yes | number | — |
  | `entries[].factors.salience` | yes | number | — |
  | `entries[].factors.relevance` | yes | number | — |
  | `entries[].factors.outcome` | yes | number | — |
  | `entries[].seen_count` | yes | integer or null | — |
  | `entries[].updated_at` | yes | string or null | — |
  | `candidates` | yes | integer | — |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/relevant" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/usage

**Aggregate usage statistics for your own activity (reads, writes, outcomes, per scope-type) over an optional period.**

`by_tool` rows are grouped by `(tool_name, outcome, scope_type, client, kind, host)` (migration 00079 added the last three). `client` is which surface called (`dashboard`/`cli`/`mcp`/`api`); `kind`/`host` are the memory taxonomy family/owner. `host` is bounded to this window's own top 20 by event count — anything else is the literal `'other'`, never an unbounded free-text value. `scope_type` may carry a legacy free-text value predating validation hardening; group by it defensively rather than assuming the closed `global|project|repo|branch|mixed|invalid` vocabulary is exhaustive.

`summary.peak_memory_count` (migration 00081) is the highest active-memory-count snapshot taken on a write event in this window — "how full WAS this account", distinct from the `/settings/plan` page's existing LIVE count. `null` when the window has no write events. No plan limit accompanies it; pair it with your own limit reading.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `period` | query | no | string | One of `24h`, `7d`, `30d`, `90d`, `all`. |
| `since` | query | no | string | Format: date-time. |
| `until` | query | no | string | Format: date-time. |
| `correlation_id` | query | no | string | Length 1–200. |

**Responses**

- `200` — Usage statistics → [UsageStatsResponse](#usagestatsresponse)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/usage" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/usage/runs

**Enumerate runs (correlation_id values) with what each one read, wrote, and touched.**

The payoff view for `?correlation_id=` on `GET /memories/usage`: that filters TO one run, this is how you discover which ones exist. Each run is a distinct `correlation_id` — a local session, a CI job, or a PR automation (`session_kind`, migration 00082) — with its first/last-seen timestamps, read/write event and record counts (the SAME broader `READ_TOOL_NAMES`/`WRITE_TOOL_NAMES` vocabulary `summarizeUsageRows` uses for `/usage`'s own summary, not `GET /memories/read-activity`'s narrower 4-tool definition), distinct scopes touched, and total duration.

Keyset-paginated (`cursor`/`next_cursor`), never OFFSET. `range` echoes the window actually queried — an unbounded request is narrowed to 90 days server-side and captioned here rather than silently answering less than "all time" implies.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `since` | query | no | string | Format: date-time. |
| `until` | query | no | string | Format: date-time. |
| `cursor` | query | no | string | — |
| `limit` | query | no | integer | Range 1–100. Default `20`. |

**Responses**

- `200` — Runs page → object

  | Field | Required | Type | Notes |
  |---|---|---|---|
  | `range` | yes | object | — |
  | `range.since` | yes | string | — |
  | `range.until` | yes | string | — |
  | `runs` | yes | array of object | — |
  | `runs[].correlation_id` | yes | string | — |
  | `runs[].session_kind` | yes | string or null | — |
  | `runs[].first_seen` | yes | string | Format: date-time. |
  | `runs[].last_seen` | yes | string | Format: date-time. |
  | `runs[].read_events` | yes | integer | ≥ 0. |
  | `runs[].records_read` | yes | integer | ≥ 0. |
  | `runs[].write_events` | yes | integer | ≥ 0. |
  | `runs[].distinct_scopes` | yes | integer | ≥ 0. |
  | `runs[].total_duration_ms` | yes | integer | ≥ 0. |
  | `next_cursor` | yes | string or null | — |

- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/usage/runs" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### GET /memories/{id}

**Get memory by ID.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `id` | path | yes | string | Format: uuid. |

**Responses**

- `200` — Memory → [Memory](#memory)
- `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/<id>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### PATCH /memories/{id}

**Update a memory.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `id` | path | yes | string | Format: uuid. |

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `value` | no | string | Length ≤ 65536. |
| `tags` | no | array of string | — |
| `source_agent` | no | string | — |
| `trigger` | no | string | — |
| `ttl_days` | no | integer | Range 1–365. |
| `clear_ttl` | no | boolean | Default `false`. |
| `kind` | no | string | One of `lesson`, `bus`, `signal`. |
| `host` | no | string | Length ≤ 64. |

**Responses**

- `200` — Updated memory → [Memory](#memory)
- `400`, `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl -X PATCH "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/<id>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /memories/{id}

**Archive a memory (soft-delete), or hard-delete it with force=true.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `id` | path | yes | string | Format: uuid. |
| `scope` | query | no | string | Non-empty. |
| `key` | query | no | string | Length 1–512. |
| `force` | query | no | string | One of `true`, `false`. Default `false`. |
| `org` | query | no | string | Length 1–50. |

**Responses**

- `204` — Archived or deleted. No body.
- `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl -X DELETE "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/<id>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /memories/read

**Batch-read memories by scope::key reference.**

Fetch several lessons in one call. Each entry in `refs` is a `scope::key` reference, parsed by the same grammar `POST /memories`'s `cited` field uses. A reference in more than one scope resolves independently rather than failing the whole call: one that is well-formed but matches no lesson is named in `missing`, while a malformed one — and every reference past the 32nd — is dropped silently and appears in neither list. `missing` is a not-found list, never a malformed-input or truncation report.

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `refs` | yes | array of string | Items ≥ 1. |

**Responses**

- `200` — Batch read result → [ReadMemoriesResponse](#readmemoriesresponse)
- `400`, `401` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/read" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"refs":["<refs>"]}'
```

### POST /memories/{id}/restore

**Restore an archived memory by ID.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `id` | path | yes | string | Format: uuid. |

**Responses**

- `200` — Restored → [RestoreResponse](#restoreresponse)
- `400`, `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/memories/<id>/restore" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

## Orgs

### GET /orgs

**List my organizations.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Responses**

- `200` — Orgs → [OrgList](#orglist)
- `401` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /orgs

**Create an organization.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `slug` | yes | string | Length 3–50. Pattern: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`. |
| `name` | yes | string | Length 1–100. |

**Responses**

- `201` — Created org → [Org](#org)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"<slug>","name":"<name>"}'
```

### GET /orgs/{slug}

**Get organization.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Responses**

- `200` — Org → [Org](#org)
- `401`, `404` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### PATCH /orgs/{slug}

**Rename organization.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `name` | yes | string | Length 1–100. |

**Responses**

- `200` — Updated org → [Org](#org)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X PATCH "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"<name>"}'
```

### DELETE /orgs/{slug}

**Delete organization — owner only.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Responses**

- `204` — Deleted. No body.
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X DELETE "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

## Members

### GET /orgs/{slug}/members

**List members.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Responses**

- `200` — Members → [OrgMemberList](#orgmemberlist)
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/members" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### PATCH /orgs/{slug}/members/{userId}

**Update member role.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |
| `userId` | path | yes | string | Format: uuid. |

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `role` | yes | string | One of `owner`, `admin`, `member`, `viewer`. |

**Responses**

- `200` — Updated. No body.
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X PATCH "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/members/<userId>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"role":"owner"}'
```

### DELETE /orgs/{slug}/members/{userId}

**Remove member.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |
| `userId` | path | yes | string | Format: uuid. |

**Responses**

- `204` — Removed. No body.
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X DELETE "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/members/<userId>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

## Invites

### GET /orgs/{slug}/invites

**List pending invites.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Responses**

- `200` — Invites → [OrgInviteList](#orginvitelist)
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/invites" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

### POST /orgs/{slug}/invites

**Send an invite.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `email` | no | string | Format: email. |
| `handle` | no | string | Non-empty. |
| `role` | no | string | One of `admin`, `member`, `viewer`. Default `member`. |

**Responses**

- `201` — Invite sent → [OrgInvite](#orginvite)
- `400`, `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/invites" \
  -H "Authorization: Bearer $LOREKIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

### DELETE /orgs/{slug}/invites/{inviteId}

**Revoke an invite.**

- **Auth:** Bearer token (`Authorization: Bearer <token>`).
- **Common headers:** `X-LoreKit-Client`, `X-LoreKit-Dry-Run` — see [Common headers](#common-headers).

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | path | yes | string | Length 3–50. |
| `inviteId` | path | yes | string | Format: uuid. |

**Responses**

- `204` — Revoked. No body.
- `401`, `403` — Error → [Error](#error)

**Example**

```bash
curl -X DELETE "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/orgs/<slug>/invites/<inviteId>" \
  -H "Authorization: Bearer $LOREKIT_TOKEN"
```

## Blog

### GET /blog/likes

**Get a blog post's like total (public).**

- **Auth:** None — public endpoint.

**Parameters**

| Name | In | Required | Type | Notes |
|---|---|---|---|---|
| `slug` | query | yes | string | Length 1–128. Pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*$`. |

**Responses**

- `200` — The post's global like total → [BlogLikes](#bloglikes)
- `400` — Error → [Error](#error)

**Example**

```bash
curl "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/blog/likes?slug=<slug>"
```

### POST /blog/likes

**Add a like to a blog post (public).**

- **Auth:** None — public endpoint.

**Request body** (`application/json`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `slug` | yes | string | Length 1–128. Pattern: `^[a-z0-9]+(?:-[a-z0-9]+)*$`. |
| `delta` | no | integer | Range 1–100. Default `1`. |

**Responses**

- `200` — The post's global like total → [BlogLikes](#bloglikes)
- `400` — Error → [Error](#error)

**Example**

```bash
curl -X POST "https://pqokxlhvnosogizsjztg.supabase.co/functions/v1/blog/likes" \
  -H "Content-Type: application/json" \
  -d '{"slug":"<slug>"}'
```

## Schemas

Named shapes the endpoints above link to, written out once.

### MemoryPage

| Field | Required | Type | Notes |
|---|---|---|---|
| `entries` | yes | array of [Memory](#memory) | — |
| `hasMore` | yes | boolean | — |
| `nextCursor` | yes | string or null | — |
| `total` | no | integer | ≥ 0. |

### Error

| Field | Required | Type | Notes |
|---|---|---|---|
| `error` | yes | string | — |
| `code` | no | string | — |
| `details` | no | object | — |

### Memory

| Field | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | string | Format: uuid. |
| `scope` | yes | string | — |
| `key` | yes | string | — |
| `value` | yes | string | — |
| `tags` | yes | array of string | — |
| `source_agent` | yes | string or null | — |
| `trigger` | yes | string or null | — |
| `created_at` | yes | string | Format: date-time. |
| `updated_at` | yes | string | Format: date-time. |
| `expires_at` | yes | string or null | Format: date-time. |
| `archived_at` | yes | string or null | Format: date-time. |
| `origin_repo` | no | string or null | — |
| `origin_branch` | no | string or null | — |
| `origin_commit` | no | string or null | — |
| `origin_pr` | no | number or null | — |
| `kind` | no | string or null | — |
| `host` | no | string or null | — |
| `seen_count` | no | integer | — |
| `read_count` | no | integer | ≥ 0. |
| `last_read_at` | no | string or null | Format: date-time. |
| `last_opened_at` | no | string or null | Format: date-time. |
| `opened_count` | no | integer | ≥ 0. |
| `cited_count` | no | integer | ≥ 0. |
| `last_cited_at` | no | string or null | Format: date-time. |
| `org_id` | no | string or null | Format: uuid. |
| `org` | no | object or null | — |
| `org.id` | yes | string | Format: uuid. |
| `org.name` | yes | string | — |
| `org.slug` | yes | string | — |
| `created_by` | no | string or null | Format: uuid. |
| `updated_by` | no | string or null | Format: uuid. |

### RestoreResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `restored` | yes | boolean | — |

### PurgeResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `purged` | yes | integer | ≥ 0. |

### ScopesResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `scopes` | yes | array of object | — |
| `scopes[].scope` | yes | string | — |
| `scopes[].count` | yes | integer | ≥ 0. |
| `scopes[].last_activity` | no | string or null | — |

### MemoryQuotaResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `plan` | yes | string | — |
| `personal` | yes | object | — |
| `personal.count` | yes | integer | ≥ 0. |
| `personal.limit` | yes | integer | ≥ 0. |
| `personal.remaining` | yes | integer | ≥ 0. |
| `org_count` | yes | integer | ≥ 0. |
| `total` | yes | integer | ≥ 0. |

### UsageStatsResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `range` | yes | object | — |
| `range.since` | yes | string or null | — |
| `range.until` | yes | string or null | — |
| `correlation_id` | yes | string or null | — |
| `summary` | yes | object | — |
| `summary.total_events` | yes | integer | ≥ 0. |
| `summary.reads` | yes | integer | ≥ 0. |
| `summary.writes` | yes | integer | ≥ 0. |
| `summary.other` | yes | integer | ≥ 0. |
| `summary.records_read` | yes | integer | ≥ 0. |
| `summary.archived` | yes | integer | ≥ 0. |
| `summary.expired` | yes | integer | ≥ 0. |
| `summary.by_outcome` | yes | object (map of integer) | — |
| `summary.peak_memory_count` | no | integer or null | ≥ 0. |
| `by_tool` | yes | array of object | — |
| `by_tool[].tool_name` | yes | string | — |
| `by_tool[].outcome` | yes | string | — |
| `by_tool[].scope_type` | yes | string or null | — |
| `by_tool[].client` | no | string or null | — |
| `by_tool[].kind` | no | string or null | — |
| `by_tool[].host` | no | string or null | — |
| `by_tool[].event_count` | yes | integer | ≥ 0. |
| `by_tool[].record_count` | yes | integer | ≥ 0. |
| `by_tool[].total_duration_ms` | yes | integer or null | ≥ 0. |
| `by_scope_type` | yes | array of object | — |
| `by_scope_type[].scope_type` | yes | string or null | — |
| `by_scope_type[].event_count` | yes | integer | ≥ 0. |

### ReadMemoriesResponse

| Field | Required | Type | Notes |
|---|---|---|---|
| `entries` | yes | array of [Memory](#memory) | — |
| `missing` | yes | array of string | — |

### OrgList

| Field | Required | Type | Notes |
|---|---|---|---|
| `entries` | yes | array of [Org](#org) | — |

### Org

| Field | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | string | Format: uuid. |
| `slug` | yes | string | — |
| `name` | yes | string | — |
| `created_at` | yes | string | Format: date-time. |

### OrgMemberList

| Field | Required | Type | Notes |
|---|---|---|---|
| `entries` | yes | array of [OrgMember](#orgmember) | — |

### OrgInviteList

| Field | Required | Type | Notes |
|---|---|---|---|
| `entries` | yes | array of [OrgInvite](#orginvite) | — |

### OrgInvite

| Field | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | string | Format: uuid. |
| `org_id` | yes | string | Format: uuid. |
| `email` | yes | string or null | — |
| `handle` | yes | string or null | — |
| `role` | yes | string | One of `owner`, `admin`, `member`, `viewer`. |
| `created_at` | yes | string | Format: date-time. |
| `expires_at` | yes | string or null | Format: date-time. |

### BlogLikes

| Field | Required | Type | Notes |
|---|---|---|---|
| `likes` | yes | integer | ≥ 0. |

### OrgMember

| Field | Required | Type | Notes |
|---|---|---|---|
| `user_id` | yes | string | Format: uuid. |
| `role` | yes | string | One of `owner`, `admin`, `member`, `viewer`. |
| `joined_at` | yes | string | Format: date-time. |
| `display_name` | yes | string or null | — |
| `avatar_url` | yes | string or null | — |
