Platform

Architecture

Platform is a Flask API with a Discord bot, a job worker and an MCP server. All four use the same code in core/ and modules/ and the same database.

Processes

ProcessEntry pointPortUse
APImain.py under gunicorn8000Every HTTP route
Discord botbot_main.pySlash commands and Jeopardy, with the cogs in modules/bot, modules/games and modules/leetcode
Job workerworker_main.pyRuns jobs from the Procrastinate queue. Postgres only
MCP servermcp_main.py8001Module tools for agents, over MCP
Dashboarddashboard/5001 (5173 in dev)The officer pages for each org and the member store (Vite, React)
Webweb/5000The old officer app (Create React App), kept for SoDA at admin.thesoda.io

The database is Postgres in production, or SQLite for a small deployment. Alembic makes the schema.

This diagram shows the processes, their clients and the outside services.

flowchart LR
  dashboard["dashboard/"] --> api["API: main.py, port 8000"]
  web["web/"] --> api
  website["thesoda.io"] --> api
  agents["Apps and agents"] -->|"Bearer plat_..."| mcp["MCP server: mcp_main.py, port 8001"]
  agents -->|"/api/tools"| api
  api --> db[("Postgres or SQLite")]
  mcp --> db
  bot["Discord bot: bot_main.py"] --> db
  worker["Job worker: worker_main.py, Postgres only"] --> db
  api -->|"REST, BOT_TOKEN"| discord["Discord"]
  bot --> discord
  api --> outside["RunPod, Notion, Google, GitHub and other services"]
  worker --> outside

The API does not need the bot. It reads Discord servers, roles and members over Discord's REST API with BOT_TOKEN (core/integrations/discord.py). The Jeopardy routes under /api/bot are the exception: they call the bot's cogs, so they work only when the bot runs in the API process (RUN_BOT_IN_API=true).

Layout

main.py, bot_main.py, worker_main.py, mcp_main.py   the four entry points
core/          shared code: config, database, jobs, tools, secrets, audit, logs, HTTP hooks, Discord and RunPod clients, hosting providers
modules/       one folder per module, not nested; registry.py mounts the blueprints, manifest.py lists categories, models, jobs and tools
alembic/       migrations
tests/         pytest; tests/contract/ checks every route a client uses
dashboard/, site/   the dashboard and member store, the docs and landing site
deploy/        the RunPod start script and the SQLite to Postgres copy script

core/ imports nothing from modules/. Only the route files (api.py, member_api.py), registry.py, cli.py and the route helpers in modules/auth/ import Flask. make ci checks both with import-linter.

How a request runs

  1. Flask-CORS adds the CORS headers for an allowed origin. The list is in main.py, plus CORS_EXTRA_ORIGINS and DASHBOARD_URL.
  2. If the path starts with a DISABLED_ROUTES prefix, the API returns 404.
  3. modules/registry.py sends the request to the module blueprint. If the module is turned off for the org in the URL, it returns 404.
  4. A decorator from modules/auth checks the caller. See Authentication.
  5. The view reads the request, calls the module's service.py and returns JSON.
  6. Before the response goes out, core/http/request_log.py logs one line, and core/http/audit_hook.py writes each successful change to audit_log.

The org comes from the URL (org_prefix or org_id), or from the machine token. The X-Organization-* headers that older clients send are used only in the request log.

Views get a database session from officer_route, machine_route or member_view in modules/auth/routes.py, or from core.db.session(). Older views call next(db_connect.get_db()) and close the session in a finally block.

Modules

A module is a folder in modules/ with only the files it needs: service.py for the logic, api.py for the routes, models.py, jobs.py and tools.py. The REST routes, jobs, tools and the bot call the same service.py functions. Writing a module gives the rules and the places to register a module.

Orgs can turn on and off the optional modules: points, storefront, calendar, games, leetcode, godfather, event_webhook, feeds, uptime, knowledge, agents, integrations, accounts and runpod. The list is OPTIONAL_MODULES in modules/organizations/service.py. The Core modules, mcp and submodules among them, are always on. When a module is off for an org, its routes return 404 for the org (module= on officer_route, machine_route or the Mount), its tools are not listed, and its jobs skip the org. With integrations off, the org has no tools of connected services. If games is off for each org of an officer, the /api/bot routes return 404 for that officer. The switches are in Organization.config["modules"]. A module with no entry is on, so an org made before the switch existed keeps the module. A new org gets an entry for each optional module: on for the modules in NEW_ORG_MODULES in modules/manifest.py, off for the others. NEW_ORG_MODULES is empty, so a new org starts with Core only. Officers add and remove modules on the dashboard Explore page, or with flask --app main org modules <prefix> --on x --off y.

Categories, modules and sub-modules

A category groups modules. A module can read sub-modules.

flowchart LR
  category["Category: Storage"] --> module["Module: knowledge"] --> submodule["Sub-module: submodules/asu"]
ModuleSub-module
IsA feature with code, in modules/<name>/Content or presets with no code of their own, in submodules/<name>/
Examplesknowledge, feeds, godfathersubmodules/asu (campus pages and live queries for knowledge), submodules/careers (feeds for feeds)
Per orgOptional modules are switched on or off for each orgAn org adds a sub-module's sources or feeds in the module that uses it
On the dashboardA card on ExploreA sub-module on the card of the module that uses it

CATALOG in modules/manifest.py gives each module a title, a description, what it needs (integration keys or settings) and the sub-modules it reads. CATEGORIES puts each module in one category:

CategoryModules
Coreauth, bot, dashboard, feeds, mcp, organizations, public, submodules, superadmin, users. Always on and not on Explore
Storageknowledge, points, storefront, accounts
AI and agentsagents, integrations
Webhooksevent_webhook, feeds
Automationscalendar, uptime
Botsleetcode, games
Computegodfather, runpod (Hosting)

The dashboard sidebar has one section for each category except Core. The routes and tools of submodules need the knowledge module. GET /api/dashboard/<org>/modules returns the catalog with the org's switches and whether each need is connected.

Jobs

A module declares a job in its jobs.py with @job(name, cron=..., retry=...) from core/jobs.py. Code starts a job with jobs.defer(name, **kwargs). Each module README lists its jobs. flask --app main jobs list lists all of them.

  • On Postgres, defer adds a row to the Procrastinate queue. The worker runs the job, tries it again if it fails, and runs the periodic jobs.
  • On SQLite, there is no queue. defer runs the job in a thread of the calling process. A thread in the API runs the periodic jobs, and worker_main.py stops.

JOBS_BACKEND=inline|procrastinate sets the choice. The tests use inline. JOBS_SCHEDULER=false stops the thread that runs periodic jobs with the inline backend; the tests set it, so no periodic job changes rows during a test. Each run of a job with audit=True (the default) is written to audit_log.

Tools and the MCP server

Apps and agents read and change Platform data through tools. A module declares a tool in its tools.py with @tool from core/tools.py. Platform serves each tool two ways:

  • Over MCP (streamable HTTP) from mcp_main.py, at http://<host>:8001/mcp.
  • Over HTTP from the API: GET /api/tools lists the tools, and POST /api/tools/<name> calls one with the arguments as the JSON body.

Both need a machine token: Authorization: Bearer plat_.... The mcp module is Core, so it is always on. A caller sees only the tools that its token scopes allow and whose module its org has turned on. The MCP card on the Tokens page of the dashboard shows the server and the header. An unknown tool and a refused tool both return 404, so a token cannot find tools it may not use. A tool always acts on the caller's org. It has no org argument.

ToolsScopeModule
batch: 1 to 25 calls of other tools, each checked as a single callthe scope of each callmcp
org.info, org.branding, org.settings, leetcode.settingsorg:readorganizations, leetcode
org.set_modules (confirm), org.set_branding, org.update_settings, leetcode.update_settings, ci.set_repossettings:writeorganizations, leetcode, dashboard
secrets.list, secrets.set (confirm), secrets.delete (confirm)secrets:manageorganizations
tokens.list, tokens.create (confirm), tokens.revoke (confirm)tokens:manageorganizations
org.overview, org.trends, notifications.list, errors.list, activity.log, ci.runsactivity:readdashboard
notifications.resolve, notifications.reopen, notifications.delete (confirm), errors.resolve, errors.reopen, errors.delete (confirm)settings:writedashboard
integrations.list, integrations.save (confirm), integrations.test, integrations.disconnect (confirm)integrations:managedashboard
webhooks.list, webhooks.save, webhooks.test, webhooks.delete (confirm)webhooks:managedashboard
events.list, calendar.settingscalendar:readcalendar
calendar.update_settings, calendar.sync, calendar.setupcalendar:managecalendar
points.leaderboardpoints:readpoints
points.entries, points.history, members.list, members.discord_rolesmembers:readpoints, users
points.award, points.import_csv, points.delete (confirm)points:writepoints
members.add, members.update, members.discord_sync (confirm)members:writeusers
store.products, store.ordersstore:readstorefront
store.save_product, store.update_orders, store.delete_products (confirm), store.delete_orders (confirm)store:writestorefront
knowledge.search, knowledge.sources, knowledge.read_source, knowledge.submodules, knowledge.settings, knowledge.runsknowledge:readknowledge
knowledge.add_document, knowledge.delete_source (confirm), knowledge.set_crawl, knowledge.crawl_now, knowledge.sync_submodule, knowledge.update_settings, knowledge.reindex (confirm)knowledge:writeknowledge
submodules.queryknowledge:readsubmodules
apps.list, apps.get, apps.pod, apps.templates, hosting.providersapps:readrunpod
apps.register, apps.create_from_template, apps.delete (confirm), apps.rollback (confirm)apps:managerunpod
apps.deploy (confirm)apps:deployrunpod
feeds.list, feeds.presets, feeds.history, feeds.save, feeds.run, feeds.delete (confirm)feeds:managefeeds
godfather.pods, godfather.pod_members, godfather.connected, godfather.settings, godfather.sessions, godfather.list_files, godfather.read_file, godfather.update_pod, godfather.update_settings, godfather.delete_session, godfather.make_folder, godfather.move_filegodfather:managegodfather
godfather.pod_action, godfather.create_pod, godfather.add_session, godfather.write_file, godfather.delete_file (each confirm)godfather:managegodfather
uptime.list, uptime.get, uptime.targetsuptime:readuptime
uptime.save, uptime.check, uptime.delete (confirm)uptime:manageuptime
github.*: the read-only tools of GitHub's MCP servergithub:readintegrations
github.*: the other tools of GitHub's MCP server (confirm)github:writeintegrations
runpod.*: the read-only tools of RunPod's MCP serverrunpod:readintegrations
runpod.*: the other tools of RunPod's MCP server (confirm)runpod:writeintegrations
notion.*: the tools of Notion's MCP server, after an officer signs innotion:read, notion:writeintegrations
gmail.*: the tools of Google's Gmail MCP server, after an officer signs ingmail:read, gmail:sendintegrations
drive.*, calendar.*: the tools of Google's Drive and Calendar MCP servers, after an officer signs ingoogle:read, google:writeintegrations
google.calendar_list, google.calendar_events, google.drive_search, google.drive_read, google.sheets_readgoogle:readintegrations
google.calendar_create_event (confirm), google.sheets_append (confirm)google:writeintegrations
google.gmail_search, google.gmail_readgmail:readintegrations
google.gmail_send (confirm)gmail:sendintegrations
notion.search, notion.read_page, notion.query_databasenotion:readintegrations
notion.create_page (confirm)notion:writeintegrations
web.searchweb:readintegrations
asu.clubs, asu.events, after an officer signs in to ASUasu:readsubmodules/asu/signin
canvas.courses, canvas.assignments, canvas.grades, canvas.announcements, canvas.calendar, canvas.assignment_grades: one member's own Canvas, after the member connects itcanvas:readaccounts

A tool marked confirm changes or deletes something that is hard to undo. It runs only when the call has confirm=true. Without it, nothing changes and the result has confirm_required, the arguments, and for apps.deploy and apps.rollback the dry run. An agent shows that to a person, then calls again with confirm=true. Over MCP, read tools have readOnlyHint and confirm tools have destructiveHint.

No tool returns a secret value or a webhook URL. tokens.create returns the new token once. It gives only the scopes and limits of the calling token. A write tool that takes ids changes up to 100 rows in one commit; if one id is missing, nothing changes. To send many calls in one request, use batch with {"calls": [{"tool", "arguments"}], "stop_on_error"}. Its result has one entry for each call: tool, ok, result or error, and status.

The flows that need a browser have no tool: the OAuth sign-in to an integration, the ASU sign-in, file upload and download on a pod, and the Godfather CLI sign-in. Superadmin routes and member routes have no tool.

Each call, allowed or refused, is a row in audit_log with action=tool <name>, source=mcp or api, and the token as the actor. A call that waits for confirm has details.confirm=pending. The MCP server keeps no session state, so you can run more than one. Start it with docker compose --profile mcp up -d mcp.

Outside services

ServiceUseCode
DiscordSign-in, role and member checks, the botcore/integrations/discord.py, modules/auth, modules/bot
ClerkMember sign-in on the public website storefrontmodules/auth/clerk.py
Notion, Google CalendarCalendar syncmodules/calendar/clients/
RunPodGodfather pods and app deploys, through the hosting provider registry in core/hosting.pycore/integrations/runpod.py
LeetCode GraphQLThe daily question and solve checksmodules/leetcode/client.py
Error logErrors of each process and the dashboard, grouped in error_groups, shown on Activity, Errorscore/error_log.py, modules/dashboard/errors.py
WebhooksOrg events (errors, failed jobs, pods, deploys, orders, new members, failed crawls) posted to Discord webhookscore/webhooks.py, modules/event_webhook/
SentryOptional: errors, logs and sampled traces if SENTRY_DSN is setcore/log.py

On this page