HTTP Routes
Written by ddxfish on .
- Docs
- Plugin Author Guide
- HTTP Routes
- All 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) |
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)
- 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
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)}]. 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. - 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 @ c2e3170 · synced
