Writing a module
This page lists the files a module can have, the places to register it, and the rules that make ci checks. The new-module skill in .agents/skills/ has the full procedure. Content for one campus or topic, such as pages to crawl and live queries, is a pack: see packs/README.md.
Files
| File | Holds | Add it when |
|---|---|---|
README.md | What the module does, a Files table and a Surface list (routes, jobs, tools, tables). A Known gaps list if the module breaks a rule on this page | always |
service.py | The logic. It takes a database session and plain values, returns plain values and raises a ServiceError. It does not import Flask | the module does more than read a table |
api.py | A Flask blueprint. Each route reads the request, calls service.py and returns JSON | the module has routes |
models.py | SQLAlchemy tables that use Base from core/db/base.py | the module keeps data |
jobs.py | Background work with @job from core/jobs.py | the module runs on a schedule or in the background |
tools.py | Tools for agents with @tool from core/tools.py | agents call the module |
If service.py has more than one concern, split it into more Flask-free files. For example, compute/ has ssh.py, files.py and schedule.py.
Register it
| What | Where |
|---|---|
| Category | CATEGORIES in modules/manifest.py. Put the module in the category of its dashboard section |
| Blueprint and URL prefix | MOUNTS in modules/registry.py, and the import at the top of that file |
| Tables | MODEL_MODULES in modules/manifest.py, then uv run alembic revision --autogenerate -m "..." |
| Jobs | JOB_MODULES in modules/manifest.py |
| Tools | TOOL_MODULES in modules/manifest.py |
| Machine token scopes | scopes.declare(...) from modules/auth/scopes.py, at the top of service.py |
| Org secrets | secrets.declare(...) or secrets.declare_prefix(...) from core/secrets.py, at the top of service.py |
| Outside services | register(...) and use(...) from core/integrations/registry.py. See integrations.md |
| Flask-free files | service.py, models.py, jobs.py and tools.py are in the "service modules do not import Flask" contract in pyproject.toml by wildcard. Add each other Flask-free file, such as crawl.py, to that list |
| Routes | tests/contract/routes.txt: run UPDATE_ROUTES=1 uv run pytest tests/contract/test_routes.py |
| Docs | The module README.md, a row under its category in modules/README.md and in the module table of README.md, the Modules: line in AGENTS.md and CLAUDE.md, and each table in docs/data-model.md |
If the module needs more than its README, add docs/modules/<name>.md, a row in docs/README.md, and the page in site/scripts/sync-docs.mjs.
tests/test_module_layout.py checks the categories, the manifest lists, MOUNTS, the Flask-free contract, the module switches, the docs rows, and that each README and docs/data-model.md name the module's jobs, tools and tables. If you forget a place, the test names the file to change.
Org switch
If orgs can turn the module off:
- Add the name and a one-line description to
OPTIONAL_MODULESinmodules/organizations/service.py. - Set
module="<name>"on theMount. Its org routes then return 404 when the module is off. For one route in a shared blueprint, useendpoint_modules. - Set
module="<name>"on each@tool. The tool then does not show for an org that turned it off. - In a job that runs for all orgs, skip an org when
organizations.module_enabled(org, "<name>")is false. - Add the name to the expected dict in
tests/contract/test_modules.py.
Routes
Use the helpers in modules/auth/routes.py. Each one checks the caller, opens a database session, finds the org and changes a ServiceError into {"error": message} with its status.
| Helper | Use it for | The view gets |
|---|---|---|
officer_route(blueprint, rule, methods) | Officer routes under /<org_prefix> | db, org, **path args |
machine_route(blueprint, rule, scope, methods) | Routes for apps and agents with a machine token | db, org, **path args |
member_view(view) | Routes for members signed in with Discord | db, org, discord_id, **path args |
from functools import partial
from flask import Blueprint
from core.http.responses import json_body
from modules.auth.routes import machine_route
from . import service
things_blueprint = Blueprint("things", __name__)
_route = partial(machine_route, things_blueprint)
@_route("/things/<string:key>", "things:write", ["PUT"])
def put_thing(db, org, key):
return service.put_thing(db, int(org.id), key, json_body()), 201A view returns a dict, or a (dict, status) tuple. For other routes, use the decorators in modules/auth/decorators.py. See Authentication.
Errors
A module has one error class, a subclass of core.errors.ServiceError:
class ThingError(ServiceError):
pass
raise ThingError("No thing with that key", 404)The route helpers and the tool runner return its message and status. A tool function thus calls the service and needs no try.
Rules that make ci checks
core/imports nothing frommodules/(import-linter).- The files in the Flask-free contract do not import Flask (import-linter).
- ruff lint and format, and the ty type check. CI also runs bandit.
tests/contract/routes.txtagrees with the routes of the app.tests/contract/checks each route that a client uses. Add a test there for a new route.alembic checkfinds no model change without a migration.tests/test_module_layout.pyfinds each module in every place listed in Register it.
Example
modules/runpod/ is a small module with every file: service.py, api.py on machine_route, models.py, jobs.py and tools.py. modules/alerts/ is the example for officer_route.