Platform

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

FileHoldsAdd it when
README.mdWhat 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 pagealways
service.pyThe logic. It takes a database session and plain values, returns plain values and raises a ServiceError. It does not import Flaskthe module does more than read a table
api.pyA Flask blueprint. Each route reads the request, calls service.py and returns JSONthe module has routes
models.pySQLAlchemy tables that use Base from core/db/base.pythe module keeps data
jobs.pyBackground work with @job from core/jobs.pythe module runs on a schedule or in the background
tools.pyTools for agents with @tool from core/tools.pyagents 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

WhatWhere
CategoryCATEGORIES in modules/manifest.py. Put the module in the category of its dashboard section
Blueprint and URL prefixMOUNTS in modules/registry.py, and the import at the top of that file
TablesMODEL_MODULES in modules/manifest.py, then uv run alembic revision --autogenerate -m "..."
JobsJOB_MODULES in modules/manifest.py
ToolsTOOL_MODULES in modules/manifest.py
Machine token scopesscopes.declare(...) from modules/auth/scopes.py, at the top of service.py
Org secretssecrets.declare(...) or secrets.declare_prefix(...) from core/secrets.py, at the top of service.py
Outside servicesregister(...) and use(...) from core/integrations/registry.py. See integrations.md
Flask-free filesservice.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
Routestests/contract/routes.txt: run UPDATE_ROUTES=1 uv run pytest tests/contract/test_routes.py
DocsThe 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:

  1. Add the name and a one-line description to OPTIONAL_MODULES in modules/organizations/service.py.
  2. Set module="<name>" on the Mount. Its org routes then return 404 when the module is off. For one route in a shared blueprint, use endpoint_modules.
  3. Set module="<name>" on each @tool. The tool then does not show for an org that turned it off.
  4. In a job that runs for all orgs, skip an org when organizations.module_enabled(org, "<name>") is false.
  5. 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.

HelperUse it forThe 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 tokendb, org, **path args
member_view(view)Routes for members signed in with Discorddb, 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()), 201

A 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 from modules/ (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.txt agrees with the routes of the app.
  • tests/contract/ checks each route that a client uses. Add a test there for a new route.
  • alembic check finds no model change without a migration.
  • tests/test_module_layout.py finds 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.

On this page