Codumentor logo Codumentor

API Routes

Plugins can expose HTTP endpoints via FastAPI routers. This guide covers creating routes, authentication, and accessing shared state.

See also: Backend Hooks, Security


Creating API Routes

Return a FastAPI APIRouter from the api_router() method on your plugin class:

def api_router(self):
    from fastapi import APIRouter, Request
    router = APIRouter(tags=["my_plugin"])

    @router.post("/action")
    async def do_action(request: Request):
        return {"result": "success"}

    return router

Routes are mounted at /ui/plugins/{plugin_id}/. For example, a plugin with plugin_id = "my_plugin" that defines a /action route will be accessible at:

POST /ui/plugins/my_plugin/action

Authentication (Automatic)

Every route returned from api_router() is automatically protected — no
code required from plugin developers. (The one way to opt out is
public_api_router(), which exists
for callers that cannot carry a session at all and shifts authentication onto
you.)

The system injects authentication middleware that:

  1. Requires authentication — Every request must have a valid session
  2. Verifies conversation ownership — If {conversation_id} is in the route path, the authenticated user must own that conversation

Route Pattern Behavior

Route PatternBehavior
/votes/{conversation_id}Auth required + ownership verified
/stats/allAuth required only

Accessing the User

The authenticated user is available via request.state, injected by the auth middleware:

@router.get("/data/{conversation_id}")
async def get_data(conversation_id: str, request: Request):
    # User is available via request.state (injected by auth middleware)
    user = request.state.user
    print(f"Request from user: {user.id}")

    # conversation_id ownership is already verified by middleware
    # No need to check manually
    return {"user_id": user.id, "conversation_id": conversation_id}

Security Notes


Unauthenticated Routes (public_api_router())

Some callers cannot present a session: an inbound webhook from a forge, an
OAuth provider redirecting a browser back to you. For those — and only
those — a plugin may also return a router from public_api_router():

def public_api_router(self):
    if not self._enabled:
        return None            # None is fine; nothing is mounted
    from .webhooks import build_webhook_router
    return build_webhook_router(self._handler)

This router differs from api_router() in two ways that are easy to miss:

api_router()public_api_router()
Mounted at/ui/plugins/<plugin_id>/…the app root, with no prefix
Session authInjected for youNone. You must authenticate the request yourself.
Conversation ownershipVerified when {conversation_id} is in the pathNot verified

You are writing an internet-facing endpoint. Nothing upstream will reject an
anonymous caller, so authenticate inside the handler before any side effect, and
reject rather than degrade when the check fails. The two in-tree consumers show
the shapes this is meant for:

Because the router mounts at the app root, its paths are in the global
namespace and can collide with core routes or another plugin's. Prefix your own
paths distinctively (e.g. /webhooks/my_plugin/…) rather than claiming a bare
generic path.

If your endpoint can be reached with a session, use api_router() instead —
the protection is free and it cannot be forgotten.


Accessing Shared State

Use request.app.state for lazy-initialized shared stores:

@router.get("/data")
async def get_data(request: Request):
    # Lazy-init stores via app state
    store = getattr(request.app.state, "my_store", None)
    if store is None:
        store = MyStore()
        request.app.state.my_store = store
    return store.get_all()

This pattern ensures your store is created once and shared across all requests to your plugin.