Smart Agent Teams

Codebase tour

Where the code for the API, web app, CLI, runner and API client lives, how each is layered, and the conventions to follow when you change it.

This page walks through each app in the order a request travels: the API that records everything, the generated client that types it, the web app and the CLI that call it, and the runner that executes runs. Read it before your first change in an area you have not touched.

API (apps/api)

FastAPI with synchronous SQLAlchemy 2 sessions on PostgreSQL. Endpoints are plain def functions, so FastAPI runs them in its threadpool.

Entry and wiring

FileRole
main.pyEntry point for uvicorn main:app. Imports the app from api/main_api.py.
api/main_api.pyBuilds the FastAPI app: lifespan (runs init_db()), structlog request logging, CORS, optional TrustedHostMiddleware, slowapi, error handlers, /health, and router mounting.
company/routers/__init__.pyLists the company routers in mount order and sets operation_id to the Python function name, so generated clients get readable names like create_task.
api/api_key_endpoints.py/api/me/api-keys. Uses the same operation-id function.
api/auth_endpoints.pyRegister, login, refresh, logout, password reset, /api/me, /api/me/preferences, and the get_current_user dependency every route uses.
api/preferences.pyThe strict schema for user preferences stored in users.settings, and the merge rules for PATCH /api/me/preferences.

The other routers mounted by main_api.py (agent_endpoints, session_endpoints, project_endpoints, user_endpoints, dashboard_endpoints, websocket_endpoints) are the legacy pre-company API. They stay mounted but are unsupported and left out of the API reference; do not extend them.

Layers of the company domain

LayerFileRules
Routerscompany/routers/{companies,agents,tasks,runs,insights}.pyOne module per area. Take ctx: CompanyContext = Depends(get_company_context) (or require_admin). Load rows with get_owned(ctx, Model, id, "Label") so cross-company ids are 404. Raise errors with not_found, bad_request or api_error. Write a docstring for every endpoint: it becomes the API reference.
Schemascompany/schemas.pyRequest models (...Create, ...Update) and responses (...Out). Status values are Literal types. Updates apply only fields that were sent (model_dump(exclude_unset=True)).
Servicescompany/services.pyRules shared by routers: queue_run, next_task_number, enforce_agent_budget, finish_run, expire_stale_runs, run_actor, spend queries.
Auditcompany/audit.pyEvery mutation calls audit.record(...) before commit() and audit.publish(...) after it. audit.diff(row, values) applies changes and returns {field: [old, new]} for the record.
Eventscompany/events.pypublish(company_id, type, **data) sends {"type": "<entity>.<verb>", "id": ..., ...}. Values are converted to strings.
Dependenciescompany/deps.pyget_company_context, require_admin, get_owned, now() (aware UTC), aware() (treats SQLite's naive datetimes as UTC).
Modelsdatabase/models.py, database/company_models.pymodels.py imports company_models at the bottom so every table is registered. The allowed status tuples live in company_models.py.

The shape of a typical mutation:

company/routers/tasks.py (simplified)
@router.patch("/tasks/{task_ref}", response_model=TaskOut)
def update_task(task_ref: str, body: TaskUpdate, ctx: CompanyContext = Depends(get_company_context)):
    """Update a task; only fields present in the body are changed. ..."""
    task = _find_task(ctx, task_ref)                 # 404 outside this company
    actor = services.run_actor(ctx, body.run_id)     # agent attribution, if any
    values = body.model_dump(exclude_unset=True, exclude={"run_id"})
    changes = audit.diff(task, values)               # apply and collect {field: [old, new]}
    if changes:
        label = f"{services.task_key(ctx, task)} {task.title}"
        verb = "moved" if set(changes) <= {"status", "sort_order"} else "updated"
        audit.record(ctx, verb, "task", task.id, label, changes, **_actor_kwargs(actor))
        ctx.db.commit()
        audit.publish(ctx, "task", "updated", task.id)   # after commit, never before
    return _task_out(ctx, task)

Conventions

  • Errors have one shape: {"error": {"code": "NOT_FOUND", "message": "...", "details": {}}}. Use an upper-case code that clients can branch on (RUN_LEASE_LOST, ALREADY_DECIDED, ORG_CYCLE).
  • Money is integer cents. A budget of 0 means no limit.
  • Times are timezone-aware UTC. Compare with aware(...) when a value may come from SQLite in tests.
  • Non-members get 404. Never return 403 for a company the caller cannot see.
  • The API records; runners execute. Do not add code that runs agents or calls a model from the API. See Architecture.
  • Migrations for every model change, additive by default. See Database.
  • Regenerate the client after any API change: bun run --cwd libs/api-client generate.

API client (libs/api-client)

@sat/api-client is the single typed boundary between the API and every TypeScript consumer.

FileRole
openapi.jsonExported from the FastAPI app by apps/api/scripts/export_openapi.py. Committed.
src/schema.d.tsGenerated by openapi-typescript. Committed. Never edit by hand.
src/client.tscreateApiClient({ baseUrl, tokens, onUnauthorized }): an openapi-fetch client plus call() (unwraps responses, throws ApiError) and authedFetch(). On a 401 it refreshes once, sharing one refresh across concurrent requests, then retries.
src/events.tssubscribeToCompanyEvents: reads the SSE stream with fetch (so it can send the Bearer header) and reconnects with backoff from 2 seconds, doubling up to 30 seconds.
src/models.tsFriendly type aliases (Task, Agent, Run, CompanyEvent, ...).
src/vocabulary.tsThe one status vocabulary shared by the web app and the CLI: task statuses and labels, board columns, priorities, agent and run status labels, roles, adapters.

Regenerate with bun run --cwd libs/api-client generate. CI type-checks the web app, CLI and docs against the committed types, so a stale client fails the build.

Web app (apps/web)

React 19 function components, React Router 8, SWR for server state, zustand for UI state, Tailwind 4 with tokens, components from @yarlisai/ui.

PathRole
src/main.tsx, src/router.tsxEntry and routes. Company pages live under /c/:companyId/... and are code-split per route. requireSignIn redirects to /login?next=....
src/pages/One file per screen: dashboard, inbox, tasks, task-detail, projects, goals, agents, agent-detail, org-chart, approvals, costs, activity, routines, run-detail, settings, sign-in and password pages, home (public homepage).
src/components/Shared pieces: shell/ (layout, sidebar, top bar, command palette, company switcher, live indicator), kanban, task-list, run-table, approval-card, dialogs, status.
src/lib/api.tsThe app's api client: base URL from VITE_API_URL, tokens in localStorage, redirect to /login when a session cannot be refreshed.
src/lib/hooks.tsOne SWR hook per resource (useTasks, useAgents, useRuns, useDashboard, ...). Keys are [resource, companyId, ...args].
src/lib/mutations.tsWrites that need shared handling (for example updateTask): call the API, refresh affected caches, toast on failure.
src/lib/live.tsuseLiveEvents(companyId) subscribes to the company's SSE stream (origin VITE_EVENTS_ORIGIN) and maps each event's entity to the SWR keys it makes stale (AFFECTS). useLiveStatus drives the live indicator.
src/lib/status.tsRe-exports the vocabulary from @sat/api-client and maps every status to its colour token (statusColor).
src/lib/preferences.ts, look.ts, format.tsAccount preferences, theme and density, locale-aware formatting.
src/stores/ui.tszustand store (persisted) for UI-only state: theme, sidebar, panels, command palette, new-task dialog.
src/index.cssThe single token source: semantic colours, status colours, brand values, fonts.

Conventions

  • Server data only through the SWR hooks in lib/hooks.ts. Do not fetch ad hoc in components. A new resource gets a hook with a [resource, companyId, ...] key, and an entry in AFFECTS in lib/live.ts so live events refresh it.
  • UI state in zustand, not in SWR or component trees that need it globally.
  • Visual values only from tokens in src/index.css (bg-background, text-muted-foreground, var(--status-...)). No raw colours in components.
  • One status vocabulary. Labels and order come from @sat/api-client (TASK_STATUSES, PRIORITIES, ...); colours from statusColor.
  • Every screen answers three questions: what is happening, does it need me, what do I do about it.

CLI (apps/cli)

Bun and commander. sat covers the control plane, the runner and the scheduler.

PathRole
src/main.tsbuildProgram(): global options and one register... call per command module. Also used by scripts/docs.ts to generate the CLI reference.
src/api.tsThe session: HTTP helpers over @sat/api-client, current company resolution, and name, prefix or id resolution for companies, agents and tasks.
src/config.tsProfiles (local, staging, prod, plus your own) and environment overrides.
src/credentials.tsCredential storage: macOS Keychain, Linux Secret Service (secret-tool), or a 0600 file. SAT_API_KEY and SAT_TOKEN are never stored.
src/commands/One module per noun: auth (login, whoami, apikey), company (companies, profiles), insights (dashboard, inbox, costs, activity), agents, tasks, planning (goals, projects, routines), runs (runs, approvals), runner (runner, scheduler), doctor, completion.
src/output.ts, src/errors.tsTable and JSON output; stable exit codes (0 ok, 2 usage, 3 auth, 4 not found, 5 conflict, 6 unavailable).
src/events.tsSSE subscription for the CLI.

Conventions: every command supports --json; errors map to stable exit codes; anything an agent might call through sat adds run_id from SAT_RUN_ID so writes are attributed to the agent. Regenerate the CLI reference after changing commands: bun run --cwd apps/cli docs (CI runs docs:check).

Runner (apps/cli/src/runner)

FileRole
runner.tsThe engine: subscribes to events, claims runs up to --concurrency, prepares a workspace, runs the adapter, streams transcript (every 2 seconds or 4 KB), heartbeats (every 30 seconds), handles cancellation and lease loss, posts the final comment, moves the task and reports the result.
adapters.tsClaudeCodeAdapter (claude -p --output-format stream-json), CodexAdapter (codex exec --json), EchoAdapter (simulation), UnsupportedAdapter. Each turns the CLI's event stream into transcript, usage and result events.
prompt.tsThe system prompt (agent identity, manager, mission, instructions) and task prompt (task, goal chain, project, last 20 comments).
workspace.tsOne git worktree per run for projects with a repository; a persistent folder per agent otherwise.
process.tsSpawns the agent CLI, reads its stdout line by line, keeps the last 4,000 characters of stderr.
scheduler.tsClient-side cron for routines and heartbeats, plus stale-run expiry.
types.tsThe adapter interface.

Tests in apps/cli/test run the runner and scheduler against in-memory fakes. See How runs work and Custom runner for the protocol.

Docs (apps/docs)

Next.js with Fumadocs. MDX pages in content/docs, the generated API reference from libs/api-client/openapi.json (lib/openapi.ts, lib/source.ts), and a link checker in scripts/check-links.ts. See Writing docs.

On this page