Skip to content

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 BaseTool
from 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-section must match an id in KAOOHI_SECTIONS (section 4’s default section id is your domain_module).
  • widget and order are required in x-kaoohi-ui; order must be unique within its section.
  • Secrets to external services are optional with a None default — calendar_api_key: SecretStr | None = Field(default=None, ...). Required secrets break test construction, and a SecretStr("") 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:

Terminal window
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.

  • 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.py via include_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.