Skip to main content

HTTP Routes

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 an int
  • A FastAPI/Starlette Response object — 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_login dependency — 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: true are 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 whose Authorization: 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 request directly; 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; Response object → 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 on media: true routes exempt — serve the file with a FileResponse, which does HTTP Range).
  • Bearer opt-in: user/plugin_state/{plugin}_mcp_key.json = {"key": "..."} lets a matching Authorization: Bearer header 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