Track A — Extension points
A fresh kaboum render boots, passes its tests, and does nothing useful — by
design. The template owns the platform plumbing; you own the domain. That
boundary is explicit: seven extension points the template scaffolds once and
expects you to fill. Paths below use booking as the domain_module of an
actor generated with project_name=kalendar.
| # | Extension point | Rendered-actor path |
|---|---|---|
| 1 | Domain client | src/booking/client.py |
| 2 | Domain models | src/booking/models.py |
| 3 | LangChain tools | src/agent/tools/booking_tools.py |
| 4 | Domain config fields | src/config.py, section 4 |
| 5 | Domain migrations | migrations/004_*.sql onward |
| 6 | System prompts | ROLE_PLAYBOOK in src/agent/agent.py |
| 7 | MCP servers | MCP_SERVERS_JSON env var — zero code |
1. Domain client — src/booking/client.py
Section titled “1. Domain client — src/booking/client.py”One class wrapping whatever external system your actor talks to (Google Calendar, Gmail, an internal API). The scaffold is deliberately minimal:
import structlog
from src.config import KalendarConfig
logger = structlog.get_logger(__name__)
class BookingClient: """Client for booking operations."""
def __init__(self, config: KalendarConfig): self.config = config logger.info("booking_client_initialized")
async def health_check(self) -> bool: """Check if the domain service is reachable.""" ...Initialize it in the src/main.py lifespan block marked
# Domain client (non-critical), and keep the constructor side-effect-free
so tests can instantiate it without credentials.
2. Domain models — src/booking/models.py
Section titled “2. Domain models — src/booking/models.py”Plain Pydantic models shared by your client and tools. Nothing framework specific goes here:
from datetime import UTC, datetime
from pydantic import BaseModel, Field
class Booking(BaseModel): """A calendar booking."""
id: str = Field(..., description="Booking identifier") title: str = Field(..., description="Event title") status: str = Field(default="active", description="Booking status") created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) metadata: dict | None = Field(default_factory=dict)3. LangChain tools — src/agent/tools/booking_tools.py
Section titled “3. LangChain tools — src/agent/tools/booking_tools.py”Tools follow the BaseTool class pattern: a Pydantic input schema, an
explicit name/description the model reads, and both _run and _arun:
from langchain_core.tools import BaseToolfrom pydantic import BaseModel, Field
from src.config import KalendarConfig
class CreateBookingInput(BaseModel): """Input schema for create_booking.""" title: str = Field(description="Event title") start_iso: str = Field(description="Start time, ISO 8601")
class CreateBookingTool(BaseTool): """Create a booking on the connected calendar."""
name: str = "create_booking" description: str = "Create a calendar booking with a title and start time." args_schema: type[BaseModel] = CreateBookingInput config: KalendarConfig = None
def __init__(self, config: KalendarConfig, **kwargs): super().__init__(**kwargs) self.config = config
def _run(self, title: str, start_iso: str) -> str: return self._arun(title, start_iso)
async def _arun(self, title: str, start_iso: str) -> str: # call the domain client here ...Tools never self-register. The orchestrator (src/agent/orchestrator.py)
builds them and feeds a ToolRegistry — named sources merged with dedupe by
tool.name, first registration winning. The base render registers two
sources, domain then mcp; integration plugins add more without touching
the registry.
4. Domain config fields — src/config.py, section 4
Section titled “4. Domain config fields — src/config.py, section 4”Section 4 of the Settings class is yours; sections 1–3 and 5+ are
template-owned. Every domain field carries x-kaoohi-* annotations that drive
the marketplace deployment form:
# ========================================================================= # 4. BOOKING (domain-specific parameters) # ========================================================================= calendar_id: str = Field( default="", description="Google Calendar ID to operate on", json_schema_extra={ "x-kaoohi-section": "booking", "x-kaoohi-managed-by": "user", "x-kaoohi-ui": {"widget": "text", "order": 30}, }, )Conventions encoded in the template’s own comments:
x-kaoohi-sectionmust match an id inKAOOHI_SECTIONS(section 4’s default section id is yourdomain_module).widgetandorderare required inx-kaoohi-ui;ordermust be unique within its section.- Secrets to external services are optional with a
Nonedefault —calendar_api_key: SecretStr | None = Field(default=None, ...). Required secrets break test construction, and aSecretStr("")default fails the config emitter’s validation.
The full annotation set and the validation rules behind it are covered in ](en/track-a/config-and-verify/).
5. Domain migrations — migrations/004_*.sql onward
Section titled “5. Domain migrations — migrations/004_*.sql onward”Migrations 001–003 are the template’s (conversations, messages, stats,
OAuth tokens) — never renumber or edit them. Yours start at 004 and follow
the house style: CREATE TABLE IF NOT EXISTS, index names prefixed
idx_{project_name}_, and a comment header stating what the migration
creates. The runner tracks applied files in {project_name}_migrations:
-- ============================================================-- 004: booking slots cache-- ============================================================
CREATE TABLE IF NOT EXISTS kalendar_booking_slots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), kompanion_id UUID NOT NULL, slot_start TIMESTAMPTZ NOT NULL, slot_end TIMESTAMPTZ NOT NULL);
CREATE INDEX IF NOT EXISTS idx_kalendar_booking_slots_kompanion ON kalendar_booking_slots(kompanion_id, slot_start);A vendored test (test_schema_sensitive_columns.py) fails if a migration
adds a sensitive column without the expected guarding — keep it green.
6. System prompts — ROLE_PLAYBOOK in src/agent/agent.py
Section titled “6. System prompts — ROLE_PLAYBOOK in src/agent/agent.py”Your actor’s domain knowledge lives in exactly one constant. The two
SYSTEM_PROMPT_* constants above it are template-owned: copier update
rewrites them, and an edit there conflicts on every template release.
ROLE_PLAYBOOK is appended to whichever prompt the role selects, so one text
covers both team and customer surfaces:
ROLE_PLAYBOOK: str = ( "Refuse bookings outside opening hours (10:00–18:00, Mon–Fri).\n" "Always confirm the customer's phone number before rescheduling.\n" "Answer in French." )Write refusals, numbered scenarios, domain vocabulary, and escalation rules —
the boundary the code enforces, said out loud. Then pin the load-bearing
sentences in tests/test_system_prompt.py (PINNED_PHRASES maps a
description to the exact substring), so a prompt regression fails loudly
instead of quietly producing a model that stopped refusing something.
7. MCP servers — MCP_SERVERS_JSON, zero code
Section titled “7. MCP servers — MCP_SERVERS_JSON, zero code”External MCP tools load at startup with no code changes. Set
MCP_SERVERS_JSON to a JSON object keyed by server name; main.py builds a
MultiServerMCPClient (langchain-mcp-adapters) and registers the loaded
tools as the registry’s mcp source alongside your domain tools:
MCP_SERVERS_JSON='{ "google-calendar": { "transport": "streamable_http", "url": "https://mcp.example/calendar", "headers": {"Authorization": "Bearer ${CAL_MCP_KEY}"} }}'A malformed or unreachable server logs a warning and is skipped — MCP is non-critical by design.
Marketplace metadata: [tool.kaoohi] in pyproject.toml
Section titled “Marketplace metadata: [tool.kaoohi] in pyproject.toml”Alongside the seven code points, the emitter reads a [tool.kaoohi] block
your kaoohi_* copier answers seeded once: slug, name, description, category,
connectors, deployment image and resources, safety/egress declarations. You
hand-edit it after generation — it is actor-owned. With your Settings fields
it produces src/kaoohi/config.json, the file the marketplace consumes. That
pipeline, and how to verify it, is the next page.
Conventions worth internalizing
Section titled “Conventions worth internalizing”- Warn, don’t crash. Every non-critical service — Redis, auth client, MCP, domain client, mesh — initializes inside try/except and logs a warning on failure. PostgreSQL is the only critical service: no database, no startup. Match this in your own wiring.
- Single writers. Each file has one owner: template files are rewritten by
copier update, actor files are not. The escape hatches (ROLE_PLAYBOOK,src/config_actor.pyviainclude_actor_config_mixin) exist so your content can live beside template-owned structure without conflict. - Structlog events, snake_case. Log events are
booking_client_initialized-style identifiers with structured fields — follow the pattern in every template module.
Beyond the canonical seven, the template scaffolds optional extension points
when you enable them: src/{domain}/runtime.py (readiness), jobs.py
(scheduler), coordination.py (mesh), src/api/panels.py (owner panel),
src/config_actor.py (actor-owned config). How-tos for all of these live in
kaboum’s
development and extension guide.