Skip to content

Modules Routes

Logan Fick edited this page Feb 15, 2026 · 10 revisions

Modules - Routes

Modules can register HTTP routes on the built-in aiohttp web server, which is shared with the webhook receiver. This is useful for web UIs, API endpoints, health checks, or anything else served over HTTP.

@on_route Decorator

The @on_route decorator registers a function as a route handler. Basic usage:

from owlbot.api import RouteContext, on_route

@on_route("/stats")
async def stats(ctx: RouteContext) -> dict:
    return {"viewers": 42, "uptime": "3h 12m"}

Parameters

Parameter Type Default Description
path str (required) URL path relative to the module's namespace.
methods list[str] | None ["GET"] HTTP methods to accept.

A route that accepts multiple methods:

@on_route("/config", methods=["GET", "POST"])
async def config_page(ctx: RouteContext) -> dict:
    if ctx.request.method == "POST":
        data = await ctx.request.json()
        # ... update something
        return {"status": "updated"}
    return {"current_config": ctx.config.as_dict()}

Namespacing

Routes are automatically prefixed under /owlbot/<module_name>/. So if the module stats registers @on_route("/dashboard"), the actual URL is:

/owlbot/stats/dashboard

This avoids conflicts between modules and with the webhook endpoint. Only the relative path is needed in the decorator.

Path Parameters

Route paths support {name} patterns for capturing dynamic segments, using the same syntax as aiohttp. When a request arrives, the URL path is matched against registered route patterns and the captured segments populate ctx.match_info.

Given the route @on_route("/users/{id}/posts/{slug}") and a request to /owlbot/my_module/users/42/posts/hello-world:

Property Value
ctx.match_info["id"] "42"
ctx.match_info["slug"] "hello-world"

For plain routes without {...} patterns, ctx.match_info is an empty dict {}.

Single Segment

{name} matches a single path segment (no /):

@on_route("/users/{id}")
async def get_user(ctx: RouteContext) -> dict:
    user_id = ctx.match_info["id"]  # e.g., "123"
    return {"user_id": user_id}

Registered as /owlbot/my_module/users/{id}, this matches /owlbot/my_module/users/123 but not /owlbot/my_module/users/123/posts.

Custom Regex

{name:regex} matches with a custom regex pattern:

@on_route("/users/{id:\\d+}")
async def get_user(ctx: RouteContext) -> dict:
    user_id = ctx.match_info["id"]  # only digits
    return {"user_id": int(user_id)}

Catch-All

{name:.*} matches the remaining path, including / characters. This is useful for prefix-style routing:

@on_route("/api/{path:.*}", methods=["GET", "POST"])
async def api_catchall(ctx: RouteContext) -> dict:
    remaining = ctx.match_info["path"]  # e.g., "users/123/posts"
    return {"path": remaining}

Note: The catch-all requires at least one character after the prefix. /api/{path:.*} matches /api/x but not /api.

Match Order

Routes are matched in registration order (first match wins). Routes defined with @on_route at import time are registered before dynamically registered routes, so they naturally get priority.

Response Types

Route handlers support four return types:

Return Type Behavior
dict Automatically serialized to JSON with application/json content type.
web.Response Returned as-is. Full control over status, headers, body.
web.FileResponse Serves a file from disk. Sets content type, length, and caching headers automatically.
None Returns a 204 No Content response.
from pathlib import Path

from aiohttp import web
from owlbot.api import RouteContext, on_route

# JSON response:
@on_route("/api/data")
async def api_data(ctx: RouteContext) -> dict:
    return {"items": [1, 2, 3]}

# Full control:
@on_route("/page")
async def html_page(ctx: RouteContext) -> web.Response:
    return web.Response(text="<h1>Hello</h1>", content_type="text/html")

# Static file:
@on_route("/logo")
async def logo(ctx: RouteContext) -> web.FileResponse:
    return web.FileResponse(Path(__file__).parent / "static" / "logo.png")

# No content:
@on_route("/webhook", methods=["POST"])
async def incoming_webhook(ctx: RouteContext) -> None:
    data = await ctx.request.json()
    ctx.logger.info(f"Received: {data}")
    # Returns 204 automatically

Error Handling

If a route handler raises an exception, the error is logged, any storage changes are rolled back, and the client receives an empty 500 response. The same applies if the handler exceeds the configured handler_timeout (default 30 seconds); it is cancelled and the client receives a 500.

URL Building

To get the full public URL for a route, use ctx.routes.url_for():

url = ctx.routes.url_for("/list")
# -> "https://owlbot.example.com/owlbot/my_module/list"

This uses the public_base_url from config. If not set, it falls back to owncast.url. Set public_base_url if Owlbot is hosted on a different domain than your Owncast instance.

Templates with Jinja2

For HTML pages, Jinja2 templates can be used. Jinja2 is already a dependency of Owlbot, so no extra install is needed. Modules that use templates should be structured as a package so the template files can be bundled alongside the code:

modules/my_module/
    __init__.py
    templates/
        list.html
# modules/my_module/__init__.py
from pathlib import Path

import jinja2
from aiohttp import web
from owlbot.api import RouteContext, on_route

_template_dir = Path(__file__).resolve().parent / "templates"
_jinja_env = jinja2.Environment(
    loader=jinja2.FileSystemLoader(_template_dir),
    autoescape=True,
)

@on_route("/list")
async def list_page(ctx: RouteContext) -> web.Response:
    items = ["apples", "oranges", "bananas"]
    template = _jinja_env.get_template("list.html")
    html = template.render(items=items)
    return web.Response(text=html, content_type="text/html")

Request Access

ctx.request is a standard aiohttp.web.Request. Some common patterns:

# Path parameters (from {name} patterns):
user_id = ctx.match_info["id"]  # e.g., "123"

# Query parameters:
format = ctx.request.query.get("format", "json")

# JSON body:
data = await ctx.request.json()

# Form body:
data = await ctx.request.post()

# Path info:
path = ctx.request.path

# Headers:
auth = ctx.request.headers.get("Authorization")

# Raw body:
body = await ctx.request.read()

Dynamic Registration

Routes can be registered and unregistered at runtime through ctx.routes:

from owlbot.api import ModuleContext, RouteContext, on_setup

async def dynamic_handler(ctx: RouteContext) -> dict:
    return {"dynamic": True}

@on_setup
async def setup(ctx: ModuleContext) -> None:
    ctx.routes.register(
        path="/dynamic",
        handler=dynamic_handler,
    )

Dynamic registration also supports path patterns:

async def user_handler(ctx: RouteContext) -> dict:
    return {"user_id": ctx.match_info["id"]}

@on_setup
async def setup(ctx: ModuleContext) -> None:
    ctx.routes.register(
        path="/users/{id}",
        handler=user_handler,
    )

Available Methods

Method Description
ctx.routes.register(path, handler, methods=...) Register a route. Accepts the same parameters as @on_route. Returns RouteInfo.
ctx.routes.unregister(path) Remove a route by path. Returns True if found.
ctx.routes.get(path) Look up a RouteInfo by path. Returns None if not found.
ctx.routes.exists(path) Check if a route exists at the path.
ctx.routes.url_for(path) Build a full public URL for a route.
ctx.routes.module_routes Property: list of all RouteInfo for the module.

RouteInfo has these fields:

Field Type Description
path str Relative path (e.g., "/list").
full_path str Full namespaced path (e.g., "/owlbot/my_module/list").
methods frozenset[str] HTTP methods.
handler RouteHandler The handler function.
module_name str Module that owns this route.

Clone this wiki locally