# Furoshiki Plugin HTTP API

**Version:** 1.1 · **Status:** Authoritative for `/api/v1/plugin/*` endpoints.

Plugins communicate with Furoshiki through a dedicated HTTP micro-API. This replaces
direct database access and provides a clean, versioned, permission-scoped contract.

---

## Authentication

Every endpoint requires a **plugin Bearer token** (`FUROSHIKI_PLUGIN_API_TOKEN`):

```
Authorization: Bearer <token>
```

Tokens are **short-lived** and **per-invocation**: `plugin_loader` issues an HMAC-signed
token before each hook runs (no write to `longmemory.sqlite`). Validation is cryptographic
— the repair API does not query SQLite for `pt1.*` tokens. Legacy UUID rows in
`plugin_tokens` are still accepted during cutover. Tokens carry the plugin's declared
permissions; attempting a write operation not declared in `PLUGIN.md` returns `403`.

**Do not use `FUROSHIKI_API_TOKEN`** — that is the operator admin key and is never
passed to plugin subprocesses.

---

## Base URL

```
http://127.0.0.1:<port>/api/v1/plugin
```

The base URL is provided in `FUROSHIKI_PLUGIN_API_URL`. The default port is `7432`
(same as the repair API). This can be overridden via `FUROSHIKI_PLUGIN_API_PORT` or
`FUROSHIKI_PLUGIN_API_URL`.

---

## Read endpoints

### `GET /api/v1/plugin/state`

Returns the current Furoshiki state snapshot (same shape as `plugin_loader._build_state_snapshot`).

**Permissions:** any valid plugin token

**Response** `200`:
```json
{
  "mood_score": 0.72,
  "emotions": {"joy": 0.6, "loneliness": 0.2},
  "needs": {"connection": 0.5, "rest": 0.3},
  "user_needs": {},
  "last_conversation_at": "2026-04-10T08:00:00Z",
  "mind_queue_depth": 3
}
```

---

### `GET /api/v1/plugin/emotion-history`

Returns `emotion_history` rows for the last N days.

**Permissions:** any valid plugin token

**Query parameters:**

| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `days` | `7` | 1–30 | How many days of history to return |

**Response** `200`:
```json
{
  "days": 7,
  "count": 42,
  "rows": [
    {
      "recorded_at": "2026-04-10T08:00:00",
      "emotions": {"joy": 0.6, "loneliness": 0.2},
      "mood_score": 0.72,
      "restlessness": 0.3,
      "emotional_intensity": 0.5,
      "emotional_coherence": 0.8
    }
  ]
}
```

---

### `GET /api/v1/plugin/recent-memories`

Returns the most recent session memory summaries from ChromaDB `memories` collection.

**Permissions:** any valid plugin token

**Query parameters:**

| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `limit` | `5` | 1–20 | Number of summaries to return |

**Response** `200`:
```json
{
  "limit": 5,
  "count": 5,
  "memories": [
    {
      "date": "2026-04-10",
      "source": "session_log",
      "text": "Talked about the weekend trip and evening plans..."
    }
  ]
}
```

---

### `GET /api/v1/plugin/memories-search`

Semantic search over the ChromaDB **`memories`** collection (session summaries and conversation memory embeddings). Complements **`GET /recent-memories`**, which returns only the newest rows by date without a query.

**Permissions:** `read_memories: true` required in `PLUGIN.md` (token must include that permission or the API returns **403**).

**Query parameters:**

| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `query` | required | 1–500 chars | Search query |
| `n` | `8` | 1–20 | Max results |
| `min_similarity` | `0.18` | 0–1 | Minimum cosine similarity threshold |
| `max_text_chars` | `600` | 200–2000 | Max characters per hit `text` field |

**Response** `200`:
```json
{
  "query": "family visit plans",
  "count": 2,
  "results": [
    {
      "date": "2026-04-10",
      "source": "session_log",
      "collection": "memories",
      "text": "Discussed a relative visiting next month and travel timing...",
      "similarity": 0.61
    }
  ]
}
```

---

### `GET /api/v1/plugin/user-facts`

Semantic search over the ChromaDB `user_facts` collection.

**Permissions:** `read_user_facts: true` required in `PLUGIN.md` (token must include that permission or the API returns **403**).

**Query parameters:**

| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| `query` | required | 1–500 chars | Search query |
| `n` | `8` | 1–20 | Max results |
| `min_similarity` | `0.18` | 0–1 | Minimum cosine similarity threshold |

**Response** `200`:
```json
{
  "query": "sleep quality",
  "count": 3,
  "results": [
    {
      "text": "Prefers to sleep by 11pm on weekdays",
      "category": "preference",
      "score": 0.72
    }
  ]
}
```

---

### `GET /api/v1/plugin/budget-summary`

Returns today's LLM spend vs configured caps.

**Permissions:** any valid plugin token

**Response** `200`:
```json
{
  "daily_cap_usd": 1.50,
  "monthly_cap_usd": 30.00,
  "alert_pct": 80.0,
  "daily_spend_usd": 0.23,
  "monthly_spend_usd": 4.10
}
```

---

## Write endpoints

### `POST /api/v1/plugin/llm`

Submit a prompt to the LLM via Furoshiki's budget-enforced infrastructure.

**Permissions:** `llm: true` required in `PLUGIN.md`

**Request body:**

```json
{
  "prompt": "Summarise this in one sentence: ...",
  "system": "You are a concise assistant.",
  "model": "fast",
  "max_tokens": 256,
  "source": "plugin:my-plugin"
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `prompt` | yes | — | The user/task prompt (max 32 000 chars) |
| `system` | no | `""` | System prompt (max 8 000 chars) |
| `model` | no | default model | Model shortcut, tier name, or OpenRouter id |
| `max_tokens` | no | `512` | Response token limit (1–4096) |
| `source` | no | `plugin:<name>` | `source` label for cost tracking |

**Response** `200`:
```json
{"ok": true, "text": "Summary: backup job finished successfully."}
```

**Error** `429` — daily/monthly budget cap exceeded.

---

### `POST /api/v1/plugin/mind-queue`

Insert one item into the `mind_queue`. Deduplicated by `dedupe_key`.

**Permissions:** `output_mind_queue: true` required in `PLUGIN.md`

**Request body:**

```json
{
  "kind": "note",
  "summary": "Long-running sync still active",
  "detail": "Process sync-tool, PID 4821, uptime 10800s",
  "dedupe_key": "sync:running-alert:2026-04-10",
  "priority": "normal"
}
```

| Field | Required | Values | Description |
|-------|----------|--------|-------------|
| `kind` | yes | `note`, `reminder`, `vibe`, `observation`, `question`, `plugin` | Item category |
| `summary` | yes | max 500 chars | Short display text |
| `detail` | no | max 2000 chars | Extended context |
| `dedupe_key` | yes | max 200 chars | Prevents duplicate inserts |
| `priority` | no | `low`, `normal`, `high`, `urgent` | Dispatch priority |

**Response** `200`:
```json
{"ok": true, "inserted": true, "dedupe_key": "sync:running-alert:2026-04-10"}
```

`inserted: false` means a row with the same `dedupe_key` already exists (no error).

**Alternative (stdout mediation):** `cron` hooks may also emit NDJSON lines to stdout:
```json
{"type": "mind_queue_item", "kind": "note", "summary": "...", "dedupe_key": "...", "priority": "normal"}
```
`plugin_loader` / `run_plugin_scheduled` will mediate this into `mind_queue` if permissions allow.

---

### `POST /api/v1/plugin/log`

Write a log line to `log_store` under source `plugin:<name>`.

**Permissions:** any valid plugin token

**Request body:**

```json
{
  "level": "info",
  "message": "Health probe completed",
  "context": {"ok": true, "latency_ms": 42}
}
```

| Field | Required | Values | Description |
|-------|----------|--------|-------------|
| `level` | no | `debug`, `info`, `warning`, `error` | Log level |
| `message` | yes | max 2000 chars | Log message |
| `context` | no | any JSON object | Structured context |

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

---

## Error responses

| Status | Meaning |
|--------|---------|
| `401` | Missing or expired Bearer token |
| `403` | Token does not have the required permission |
| `422` | Validation error (missing field, out-of-range value) |
| `429` | LLM budget cap exceeded (`/llm` only) |
| `500` | Unexpected server error |

---

## Using the SDK

The `furoshiki_plugin_sdk` module (on `PYTHONPATH` for all plugin subprocesses) wraps
all endpoints with a simple synchronous client:

```python
from furoshiki_plugin_sdk import PluginClient

client = PluginClient()  # reads FUROSHIKI_PLUGIN_API_URL + FUROSHIKI_PLUGIN_API_TOKEN

state   = client.get_state()
history = client.get_emotion_history(days=3)
facts   = client.get_user_facts("sleep schedule", n=5)
mem_q   = client.search_memories("weekend trip", n=6, min_similarity=0.2)
budget  = client.get_budget_summary()

text    = client.call_llm("Summarise: " + some_text, max_tokens=128)

client.enqueue(
    kind="note",
    summary="Backup finished",
    dedupe_key="backup:status:2026-04-10",
)
client.log("info", "Plugin finished", context={"ms": 142})
```

See `scripts/furoshiki_plugin_sdk.py` for the full API.

---

## Token lifecycle

1. `plugin_loader` calls `issue_plugin_token(name, permissions, timeout_sec)` → returns an HMAC-signed `pt1.*` bearer string (no SQLite write).
2. Token is injected as `FUROSHIKI_PLUGIN_API_TOKEN` in the subprocess env.
3. Plugin subprocess runs; the repair API validates the token signature and expiry on every request.
4. After the subprocess exits (or times out), `plugin_loader` calls `revoke_plugin_token(token)` (in-memory blocklist for signed tokens).
5. Legacy `plugin_tokens` SQLite rows are pruned periodically; new hooks do not create them.

The token lifetime is the hook's declared `timeout` plus a 30-second grace period.
If the API server is not running (Brain not started), all plugin API calls will fail with
a connection error — plugins should handle this gracefully and continue with degraded behavior.
