Route Directory Golden Path

The recommended file-by-file layout for an app-shell route in Chirp's filesystem routing

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

When you build an app-shell application — a persistent topbar, sidebar, and tabs around your pages — every route ends up needing the same handful of files. This page is the recommended layout for one section member and one detail route: the smallest set of files that gives you tabs, breadcrumbs, a data loader, and a mutation handler with no boilerplate you don't need.

It assumes you already know Chirp's filesystem routing and app shell; here we show only the canonical file-by-file recipe.

When to use each file

A route directory holdspage.py and page.htmlplus optional cascade files. Reach for one of the special files only when you need what it provides:

You need… Use this file
Route metadata (title, section, breadcrumb) _meta.py
Context shared down the tree (load an entity once) _context.py
POST handlers for mutations _actions.py
The handler and its template page.py + page.html
A layout wrapper for this subtree _layout.html
Heavier view assembly than a_context.pyshould hold _viewmodel.py

The cascade rules — how_meta.py, _context.py, and _layout.htmlflow down to child routes — live in filesystem routing. This page only shows which file does which job.

Build an app-shell route

A section-member page needs three files. Build them in order.

  1. 1

    Declare metadata in `_meta.py`

    A staticRouteMetagives the shell its title, section, breadcrumb label, and shell mode.

    from chirp.pages.types import RouteMeta
    
    META = RouteMeta(
        title="Skills",
        section="discover",
        breadcrumb_label="Skills",
        shell_mode="tabbed",
    )
    

    section ties this page to a registered Sectionso the shell knows which tabs to render.

  2. 2

    Load domain data in `page.py`

    The handler returns a [[docs/about/core-concepts/return-values|Page]] — a fragment for htmx swaps, a full page for browser navigation. It carries only your data; the shell, tabs, and breadcrumbs come from the section.

    from chirp import Page
    
    def get():
        return Page("page.html", "content", items=load_items())
    
  3. 3

    Render standard blocks in `page.html`

    The page template fills the shell's content region. Put your markup inside the page_contentblock.

    {% block page_root %}
    {% block page_root_inner %}
    {% block page_content %}
      {{ items }}
    {% end %}
    {% end %}
    {% end %}
    

Register the section

Sections live inapp.py. Register each section, then mount your pages.

from chirp.pages.types import Section, TabItem

app.register_section(Section(
    id="discover",
    label="Discover",
    tab_items=(
        TabItem(label="Skills", href="/skills"),
        TabItem(label="Chains", href="/chains"),
    ),
    breadcrumb_prefix=({"label": "App", "href": "/"},),
))
app.mount_pages("pages")

See shells for how sections, tabs, and breadcrumbs render into the surrounding chrome.

Add a detail route

A dynamic segment like{name}/follows the same three-file shape, with two files that vary by parameter. The cascade injects the loaded entity into the handler by name, sopage.pystays a one-liner.

_meta.py — a meta()callable for a per-entity title:

from chirp.pages.types import RouteMeta

def meta(name: str) -> RouteMeta:
    return RouteMeta(title=f"Skill: {name}", breadcrumb_label=name)

_context.py — load the entity once, or raise NotFound:

from chirp import NotFound

def context(name: str) -> dict:
    skill = store.get(name)
    if not skill:
        raise NotFound()
    return {"skill": skill}

page.py — the handler receives skillfrom the cascade context by parameter name:

from chirp import Page

def get(skill):
    return Page("page.html", "content", skill=skill)

Add a mutation route

Mutations live in_actions.py. Decorate each handler with @action; the decorator name is the value templates send in the_actionform field, which dispatches to the matching handler.

from chirp import Fragment
from chirp.pages.actions import action

@action("save")
def save(skill_id: str, name: str):
    update_skill(skill_id, name)
    return Fragment("page.html", "skill_row", skill=load_skill(skill_id))

The handler receives skill_id from the cascade context and namefrom the form field namedname. Each parameter is resolved by name against path params, cascade context, then form fields — there is no automatic whole-form binding.

See [[docs/build-apps/forms-data/forms-validation|form validation and_action dispatch]] for how the form field maps to the handler and how to validate input.

Gate a route withauth

RouteMeta.authdeclares the authentication/authorization a page requires. It is enforced before the handler runs, sharing the exact gate logic (and audit events) of the@login_required / @requiresdecorators.

The simplest form is a string:

META = RouteMeta(auth="required")   # any authenticated user
META = RouteMeta(auth="admin")      # the single permission "admin"
  • None / "none" / "optional" / ""— open, no gate.
  • "required"— an authenticated user (browser → login redirect, API → 401).
  • any other string — a single required permission (missing → 403).

For permission sets,any/allmatching, or a named policy, use a structured AuthSpec. RouteMetais static serializable data, so a policy is named by string, never a live callable:

from chirp.pages.types import RouteMeta, AuthSpec

# Needs ALL of these permissions:
META = RouteMeta(auth=AuthSpec(permissions=("editor", "publisher"), mode="all"))

# Needs ANY one of these:
META = RouteMeta(auth=AuthSpec(permissions=("admin", "moderator"), mode="any"))

# A named policy (resolved against the app policy registry):
META = RouteMeta(auth=AuthSpec(policy="is_owner"))

A dynamic meta()can return the same structured auth (a dict works too, {"auth": {"permissions": ["admin"], "mode": "any"}}) and is enforced identically to a staticMETA.

Register the permission names and policy callables during app setup so the [[docs/quality/contracts-debugging/categories#auth_spec|auth_speccontract check]] can validate every declaredauthat startup:

app.register_permission("editor")
app.register_permission("publisher")
app.register_policy("is_owner", lambda user, request: user.id == request.path_params["owner_id"])

A policy callable receives (user, request)and returns truthy to allow (sync or async). AnAuthSpec.policynaming an unregistered policy fails loud — the auth_spec check flags it at startup and the gate 500s at request time rather than silently denying. Gating a route viaRouteMeta.authalso requires AuthMiddlewarein the stack (see the [[docs/quality/contracts-debugging/categories#auth_middleware|auth_middleware]] check).