The full-stack checklist for adding a feature to Aexy. Use this as a paste-and-tick list when you start a new module.
Decide the shape#
Before writing code, answer:
- Is this a new app (a new entry in the sidebar with its own pages) or an addition to an existing module?
- What workspace permissions does it need? Existing ones (browse
models/permissions.py) or a new permission slug? - Is there background work? Sync, scheduled jobs, LLM calls — those go through Temporal, not request handlers.
- Does it need to be visible to external users / public URLs? That's a deliberate decision — public endpoints live under
/api/v1/public/...and bypass the workspace scope.
Backend#
For a new resource called Widget:
1. Model#
backend/src/aexy/models/widget.py:
import uuid
from sqlalchemy import Column, ForeignKey, String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from aexy.core.database import Base
class Widget(Base):
__tablename__ = "widgets"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
workspace_id = Column(UUID(as_uuid=True), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True)
created_by_id = Column(UUID(as_uuid=True), ForeignKey("developers.id"), nullable=False)
name = Column(String, nullable=False)
config = Column(JSONB, default=dict)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
Then — and this matters — register the model:
# backend/src/aexy/models/__init__.py
from aexy.models.widget import Widget
__all__ = [..., "Widget"]
Models are not auto-discovered. If you skip this, Base.metadata.create_all() won't include the table and Alembic-style introspection won't see it either.
2. Migration#
backend/scripts/migrate_<NNNN>_add_widgets.sql:
-- Adds the widgets table for the Widgets feature.
BEGIN;
CREATE TABLE IF NOT EXISTS widgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_by_id UUID NOT NULL REFERENCES developers(id),
name TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_widgets_workspace ON widgets (workspace_id);
COMMIT;
Migrations run in alphabetical order. Use a numeric prefix (zero-padded to four digits) to keep them sorted.
Run:
docker exec aexy-backend python scripts/run_migrations.py --dry-run # preview
docker exec aexy-backend python scripts/run_migrations.py # apply
Don't create Alembic migrations. Alembic is a transitive dep that isn't used.
3. Schemas#
backend/src/aexy/schemas/widget.py:
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class WidgetCreate(BaseModel):
name: str
config: dict = {}
class WidgetUpdate(BaseModel):
name: str | None = None
config: dict | None = None
class WidgetResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
workspace_id: UUID
created_by_id: UUID
name: str
config: dict
created_at: datetime
updated_at: datetime
Pydantic v2 only. Don't mix model_config = ConfigDict(...) with class Config:.
4. Service#
backend/src/aexy/services/widget_service.py:
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from aexy.models.widget import Widget
from aexy.schemas.widget import WidgetCreate, WidgetUpdate
class WidgetService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_for_workspace(self, workspace_id: UUID) -> list[Widget]:
result = await self.db.execute(
select(Widget).where(Widget.workspace_id == workspace_id).order_by(Widget.created_at.desc())
)
return list(result.scalars().all())
async def create(self, workspace_id: UUID, developer_id: UUID, data: WidgetCreate) -> Widget:
widget = Widget(
workspace_id=workspace_id,
created_by_id=developer_id,
name=data.name,
config=data.config,
)
self.db.add(widget)
await self.db.flush()
return widget
Business logic lives here, not in the handler.
5. Router#
backend/src/aexy/api/widgets.py:
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from aexy.api.developers import get_current_developer
from aexy.core.database import get_db
from aexy.models.developer import Developer
from aexy.schemas.widget import WidgetCreate, WidgetResponse
from aexy.services.widget_service import WidgetService
from aexy.services.permission_service import PermissionService
router = APIRouter(
prefix="/workspaces/{workspace_id}/widgets",
tags=["Widgets"],
)
@router.get("", response_model=list[WidgetResponse])
async def list_widgets(
workspace_id: UUID,
current_user: Developer = Depends(get_current_developer),
db: AsyncSession = Depends(get_db),
):
if not await PermissionService(db).check_permission(str(workspace_id), str(current_user.id), "can_view_widgets"):
raise HTTPException(status_code=403, detail="Permission denied")
return await WidgetService(db).list_for_workspace(workspace_id)
@router.post("", status_code=201, response_model=WidgetResponse)
async def create_widget(
workspace_id: UUID,
data: WidgetCreate,
current_user: Developer = Depends(get_current_developer),
db: AsyncSession = Depends(get_db),
):
if not await PermissionService(db).check_permission(str(workspace_id), str(current_user.id), "can_manage_widgets"):
raise HTTPException(status_code=403, detail="Permission denied")
return await WidgetService(db).create(workspace_id, current_user.id, data)
6. Mount the router#
backend/src/aexy/api/__init__.py:
from aexy.api.widgets import router as widgets_router
...
app_router.include_router(widgets_router)
Without this, your endpoints 404 even though the file exists.
7. Permissions (if you added a new slug)#
backend/src/aexy/models/permissions.py — add can_view_widgets and can_manage_widgets to PERMISSIONS, with a category, description, and which built-in templates grant them by default. Then run the migration that backfills the new permission onto existing roles where appropriate (or expose a self-service grant in the roles UI).
8. Background work (if applicable)#
If the feature needs async work, add a Temporal activity per temporal.md — not BackgroundTasks. Dispatch from your service:
await dispatch("recompute_widget_stats", RecomputeWidgetInput(widget_id=str(widget.id)), task_queue=TaskQueue.ANALYSIS, workflow_id=f"widget-stats-{widget.id}")
Frontend#
9. App definition (if it's a new app)#
If the feature deserves its own sidebar entry — say it's a new "Widgets" app — update both of these in lockstep:
frontend/src/config/appDefinitions.tsbackend/src/aexy/models/app_definitions.py
The slug must match. Missing the backend half means workspaces can't enable the app; missing the frontend half hides it from the sidebar.
Also update frontend/src/config/sidebarLayouts.ts with the navigation entry.
10. Page#
frontend/src/app/(app)/widgets/page.tsx:
"use client";
import { useTranslations } from "next-intl";
import { useWidgets } from "@/hooks/useWidgets";
export default function WidgetsPage() {
const t = useTranslations("widgets");
const { widgets, isLoading, create } = useWidgets();
if (isLoading) return <div>{t("loading")}</div>;
return (
<div>
<h1>{t("title")}</h1>
<ul>
{widgets.map((w) => (
<li key={w.id}>{w.name}</li>
))}
</ul>
</div>
);
}
All user-facing strings go through useTranslations() — no hardcoded English. See i18n.md.
11. Hook#
frontend/src/hooks/useWidgets.ts — wrap React Query around the generated client:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { widgetsApi } from "@/lib/api";
export function useWidgets(workspaceId: string) {
const qc = useQueryClient();
const query = useQuery({
queryKey: ["widgets", workspaceId],
queryFn: () => widgetsApi.list(workspaceId),
});
const create = useMutation({
mutationFn: (data) => widgetsApi.create(workspaceId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["widgets", workspaceId] }),
});
return { widgets: query.data ?? [], isLoading: query.isLoading, create: create.mutate };
}
Don't hand-edit frontend/src/lib/api.ts — it's generated from the OpenAPI schema.
12. Translations#
frontend/messages/en/widgets.json:
{ "widgets": { "title": "Widgets", "loading": "Loading widgets..." } }
Add frontend/messages/hi/widgets.json with the same keys (Hindi values, technical terms in English). Run npm run i18n:merge.
Tests#
13. Backend#
backend/tests/unit/test_widget_service.py — service-level tests use SQLite in-memory by default. Watch for Postgres-specific features (JSONB, ARRAY) that won't work in SQLite — those need integration tests against a real PG container.
14. Frontend#
- Unit:
frontend/src/__tests__/widgets.test.tsxwith Vitest. - E2E:
frontend/e2e/widgets.spec.tswith Playwright if it's a user-visible flow.
Verify#
cd backend && pytest -k widget— service + API tests greencd backend && ruff check src/andmypy src/— linters greencd frontend && npm run lint && npm run test- Start
docker-compose up -d, hit the page in a browser, exercise the golden path, then at least one error path - For features with background work, watch the Temporal UI at http://localhost:8080 and verify activities succeed
Don't#
- Don't add new tasks to
aexy/processing/celery_app.py. That file is a deprecated stub; new work goes through Temporal. - Don't create Alembic migrations. Use the SQL migration system.
- Don't put logic in the handler. Handlers are wiring.
- Don't hand-edit
frontend/src/lib/api.ts. - Don't introduce a new public endpoint unless you've considered the authentication model — the rest of the system assumes a logged-in user with a workspace.