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
Declare metadata in `_meta.py`
A static
RouteMetagives 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", )sectionties this page to a registeredSectionso the shell knows which tabs to render. - 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
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.
Advanced: fragment and shell updates
For partial swaps, return a [[docs/build-apps/html-fragments/fragments|Fragment]]
of a named block inpage.html. To also update regions of the surrounding shell
in the same response — a counter in the topbar, a breadcrumb — emit out-of-band
swaps that target registered shell regions.
Out-of-band region updates fail loud: a swap that targets a block the layout does not define raises rather than silently wiping the DOM. Register and validate these targets through the OOB registry.
For shell-region updates specifically, Chirp exposes ashell_actionscontext
key. The exact mechanism is documented with the shell, not here.
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).