HTTP Routes
Written by ddxfish on .
- Docs
- Plugin Author Guide
- HTTP Routes
- All docs ↓
You are reading the dev branch of the docs, which may describe unreleased changes. Read the main docs.
Plugins can register custom HTTP endpoints. Auth, CSRF, and rate limiting are enforced by the framework — your handler code never touches any of that.
Manifest Declaration
{
"capabilities": {
"routes": [
{
"method": "POST",
"path": "capture/{request_id}",
"handler": "routes/capture.py:handle_capture"
}
]
}
}
The full URL becomes: POST /api/plugin/{plugin_name}/{path}
For the example above: POST /api/plugin/webcam/capture/abc123
Route Fields
| Field | Type | Required | Description |
|---|---|---|---|
method |
string | No | GET, POST, PUT, or DELETE (default: GET) |
path |
string | Yes | URL path (supports {param} placeholders) |
handler |
string | Yes | file:function reference (default function: handle) |
media |
bool | No | true for a door that serves file bytes to a <video>, <audio> or <img> (a FileResponse). Its GETs skip the per-plugin rate meter: a seeking player fires Range requests in bursts and a thumbnail grid is one GET per tile. Other verbs on the route stay metered |
Handler Signature
The framework calls your handler with keyword arguments: every {name} path param, plus body, settings, credentials, query, and request. Always end your signature with **_ to absorb the ones you don't use — otherwise the call raises TypeError on the first request (the framework always passes all of them).
def handle_capture(request_id, body=None, settings=None, **_) -> dict:
"""
Injected keyword args (declare only what you need, then **_):
request_id: path param extracted from {request_id}
body: parsed JSON body (POST/PUT/DELETE — yes, DELETE bodies
are parsed; empty dict for GET, multipart, or non-object JSON)
settings: plugin settings from user/webui/plugins/{name}.json
credentials: the credentials manager (resolve secrets server-side)
query: dict of URL query-string params
request: the raw Starlette Request object
Returns:
dict (JSON, HTTP 200), a (dict, status_code) tuple, or a
FastAPI/Starlette Response object
"""
return {"status": "ok"}
Path parameters arrive as keyword arguments matching the {name} in your path pattern. Because body, settings, credentials, query, and request are always passed, your handler must accept them by name or swallow them with **_.
Return Values
A handler can return any of:
dict— serialized to JSON with HTTP 200(dict, status_code)tuple — serialized to JSON with that status code. This is how you return errors:return {"error": "no such item"}, 404. The tuple only unpacks when it has exactly two elements and the second is anint- A FastAPI/Starlette
Responseobject — passed through untouched (files, custom headers, streaming)
Without the tuple convention a (dict, 404) return would serialize as a JSON array with HTTP 200 — the framework unpacks it so error statuses actually reach the client.
Security
All of the following are enforced automatically — you cannot disable them:
- Authentication:
require_logindependency — session or API key required - CSRF: Middleware validates tokens on POST/PUT/DELETE from browser sessions
- Rate limiting: 60 GET / 30 non-GET requests per minute, per plugin (bucketed by session — or by bearer-token hash when bearer auth is used). GETs on a route declared
media: trueare exempt — that is the one door for streaming a file to the browser - Bearer-token auth (opt-in): a plugin may add (never weaken) a bearer path by writing
user/plugin_state/{plugin}_mcp_key.json({"key": "..."}). A request whoseAuthorization: Bearer <key>matches then bypasses session login — used by MCP clients. Session CSRF still cannot be disabled.
Example: Webcam Capture Endpoint
core/plugins/webcam/
plugin.json
routes/capture.py
tools/webcam.py
plugin.json:
{
"name": "webcam",
"version": "1.0.0",
"capabilities": {
"tools": ["tools/webcam.py"],
"routes": [
{
"method": "POST",
"path": "capture/{request_id}",
"handler": "routes/capture.py:handle_capture"
}
]
}
}
routes/capture.py:
import threading
# Pending capture requests: {request_id: {"event": Event, "image": None}}
_pending = {}
_lock = threading.Lock()
def create_request(request_id, timeout=15):
"""Called by the tool — blocks until browser POSTs the image."""
event = threading.Event()
with _lock:
_pending[request_id] = {"event": event, "image": None}
event.wait(timeout=timeout)
with _lock:
data = _pending.pop(request_id, {})
return data.get("image")
def handle_capture(request_id: str, body: dict, **_) -> dict:
"""Called by the browser — delivers the captured image."""
with _lock:
req = _pending.get(request_id)
if not req:
return {"error": "No pending request"}
req["image"] = body
req["event"].set()
return {"status": "ok"}
Notes
- Request bodies are parsed for POST, PUT, and DELETE (multipart is left for the handler to read from
requestdirectly; a non-object JSON body arrives as{}) - Routes are registered on plugin load and removed on unload
- Hot reload (
POST /api/plugins/{name}/reload) re-registers routes - Handlers can be sync or async — async handlers are awaited directly, sync handlers run in a threadpool
- Path parameters only match single path segments (no slashes)
Reference for AI
- Declare:
capabilities.routes=[{method: GET|POST|PUT|DELETE (default GET), path (supports {param}, single-segment match only), handler: "file.py:function" (default function: handle), media: bool (optional; a file door for <video>/<audio>/<img> — its GETs skip the rate meter)}]. Mounted at/api/plugin/{plugin_name}/{path}. - Handler kwargs ALWAYS passed: every path param +
body+settings+credentials+query+request— end the signature with**_or the first request raises TypeError. body: parsed JSON dict for POST/PUT/DELETE (DELETE bodies ARE parsed — confirm tokens ride them);{}for GET, multipart requests, unparseable JSON, or a non-object JSON body.- Returns:
dict→ JSON 200;(dict, int)2-tuple → JSON with that status code;Responseobject → passed through; anything else → FastAPI default serialization. - Sync handlers run in a threadpool; async handlers are awaited directly.
- Enforced, not disableable: session auth (
require_login), CSRF on POST/PUT/DELETE from browser sessions, rate limit 60 GET / 30 non-GET per minute per plugin (GETs onmedia: trueroutes exempt — serve the file with aFileResponse, which does HTTP Range). - Bearer opt-in:
user/plugin_state/{plugin}_mcp_key.json={"key": "..."}lets a matchingAuthorization: Bearerheader bypass session login (rate-bucketed by token hash). Additive only — session CSRF can't be weakened. - Routes register on load, drop on unload, re-register on hot reload.
Edit this page on GitHub · docs/plugin-author/routes.md @ a5fa790 · synced
