agnes-the-ai-analyst/docs/archive/superpowers/plans/2026-04-30-customizable-welcome-prompt.md
ZdenekSrotyr a48524509a
docs: consolidate and de-clutter the documentation tree (#306)
CLAUDE.md rewritten (708 -> ~320 lines): four overlapping release
sections collapsed to one, stale v1->v35 schema history dropped (it
lives in CHANGELOG), marketplace endpoint internals and verbose
process sections moved out or tightened.

New focused docs:
- docs/RELEASING.md - release process, deploy workflows, CI quirks
  (RELEASE_TEMPLATE.md folded in as an appendix)
- docs/marketplace.md - marketplace ingestion + re-serving internals
- docs/README.md - documentation index by audience, linked from
  README.md and CLAUDE.md

Archived under docs/archive/: docs/superpowers/ (52 historical
planning artifacts), HACKATHON.md, pd-ps-comments.md,
security-audit-2026-04.md, future/NOTIFICATIONS.md.

Removed the docs/auto-install.md stub. Fixed dangling links in
connectors/jira/README.md and dev_docs/README.md, repointed
code/doc references to archived paths.
2026-05-14 18:54:22 +00:00

51 KiB

Customizable Welcome Prompt Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the analyst-bootstrap CLAUDE.md ("welcome prompt") customizable per Agnes instance via admin UI, while keeping a sensible vendor-agnostic default that ships with OSS. Server renders the template with Jinja2 against a vetted context (instance config, registered tables, marketplaces filtered by caller's RBAC, user identity).

Architecture:

  • Default template stays at config/claude_md_template.txt, converted to Jinja2 syntax ({{ name }}). It is the seed and the fallback when no admin override exists.
  • Override stored in system.duckdb as a single-row welcome_template table (schema bump v14 → v15). NULL content means "use shipped default".
  • New module src/welcome_template.py resolves the active template (DB override or file) and renders it via jinja2.Environment(undefined=StrictUndefined) against a dataclass-built context.
  • New endpoint GET /api/welcome (auth-required) returns the rendered markdown for the calling user — context includes RBAC-filtered marketplaces, user groups, etc.
  • Admin endpoints GET /PUT /DELETE /api/admin/welcome-template manage the raw template; admin UI page /admin/welcome provides a textarea editor with a placeholder cheatsheet.
  • CLI da analyst setup fetches the rendered markdown from /api/welcome instead of doing local str.replace. Falls back to embedded minimal template on 404 (older servers).
  • Pre-existing bug fixed in passing: _get_instance_name calls /api/health expecting instance_name, but /api/health only returns {"status": "ok"}. The new /api/welcome flow makes that call obsolete; the helper is deleted.

Tech Stack: FastAPI, DuckDB, Jinja2 (already in pyproject.toml), Typer (CLI), pytest.

Depends on: Schema v14 already shipped; this builds v15 on top.


File Structure

Created:

  • src/repositories/welcome_template.py — DB CRUD for the override row (~50 LoC).
  • src/welcome_template.py — context builder + renderer (~120 LoC).
  • app/api/welcome.pyGET /api/welcome + admin CRUD router (~110 LoC).
  • app/web/templates/admin_welcome.html — admin editor page.
  • tests/test_welcome_template_renderer.py — renderer unit tests.
  • tests/test_welcome_template_api.py — endpoint tests.
  • tests/test_welcome_template_migration.py — v14→v15 migration test.
  • docs/welcome-template.md — operator-facing reference (placeholders, examples).

Modified:

  • config/claude_md_template.txt — convert {name}{{ name }}, expand to use new placeholders.
  • src/db.py — bump SCHEMA_VERSION = 15, add table to _SYSTEM_SCHEMA, add _V14_TO_V15_MIGRATIONS, wire it into the migration ladder.
  • app/instance_config.py — add get_sync_interval() helper reading instance.sync_interval with default "1 hour".
  • config/instance.yaml.example — add commented sync_interval example under instance:.
  • app/main.pyapp.include_router(welcome_router).
  • app/web/router.py — add /admin/welcome GET handler.
  • cli/commands/analyst.py — replace _generate_claude_md body, drop _get_instance_name, drop --sync-interval CLI flag (server now owns it).
  • CHANGELOG.md[Unreleased] Added entry.

Task 1: DB schema migration v14 → v15

Files:

  • Modify: src/db.py

  • Test: tests/test_welcome_template_migration.py

  • Step 1: Write the failing migration test

Create tests/test_welcome_template_migration.py:

"""v14 → v15 migration: adds welcome_template singleton table."""

from pathlib import Path

import duckdb
import pytest

from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version


def _open(path: Path) -> duckdb.DuckDBPyConnection:
    return duckdb.connect(str(path))


def test_v15_creates_welcome_template_table(tmp_path):
    db_path = tmp_path / "system.duckdb"
    conn = _open(db_path)
    # Pretend we're on v14: write a v14-shaped DB by running schema then
    # rolling the version row back.
    _ensure_schema(conn)
    conn.execute("UPDATE schema_version SET version = 14")
    conn.execute("DROP TABLE IF EXISTS welcome_template")
    conn.close()

    # Re-open: migration ladder runs.
    conn = _open(db_path)
    _ensure_schema(conn)
    assert get_schema_version(conn) == SCHEMA_VERSION
    # Singleton row must exist with NULL content (= use shipped default).
    rows = conn.execute(
        "SELECT id, content, updated_at, updated_by FROM welcome_template"
    ).fetchall()
    assert len(rows) == 1
    assert rows[0][0] == 1  # singleton id
    assert rows[0][1] is None  # NULL = default
  • Step 2: Run the test to verify it fails

Run: pytest tests/test_welcome_template_migration.py -v Expected: FAIL — welcome_template table does not exist.

  • Step 3: Add table to _SYSTEM_SCHEMA and bump version

In src/db.py, change SCHEMA_VERSION = 14 to:

SCHEMA_VERSION = 15

Append to _SYSTEM_SCHEMA (the big string near the top):

-- v15: customizable analyst-bootstrap welcome prompt.
-- Singleton row (id=1). NULL content means "use the default template
-- shipped at config/claude_md_template.txt"; admin-edited override
-- stores the raw Jinja2 source string.
CREATE TABLE IF NOT EXISTS welcome_template (
    id INTEGER PRIMARY KEY DEFAULT 1,
    content TEXT,
    updated_at TIMESTAMP,
    updated_by VARCHAR,
    CONSTRAINT singleton CHECK (id = 1)
);
  • Step 4: Add migration ladder entry

In src/db.py, add a module-level constant near the other _VN_TO_VN1_MIGRATIONS:

_V14_TO_V15_MIGRATIONS = [
    """CREATE TABLE IF NOT EXISTS welcome_template (
        id INTEGER PRIMARY KEY DEFAULT 1,
        content TEXT,
        updated_at TIMESTAMP,
        updated_by VARCHAR,
        CONSTRAINT singleton CHECK (id = 1)
    )""",
    "INSERT INTO welcome_template (id, content) VALUES (1, NULL) ON CONFLICT (id) DO NOTHING",
]

In the migration ladder inside _ensure_schema (the block starting if current < 14:), append after the v14 branch:

            if current < 15:
                for sql in _V14_TO_V15_MIGRATIONS:
                    conn.execute(sql)

Also seed the singleton on fresh installs. In _ensure_schema, after the existing INSERT INTO schema_version (version) VALUES (?) for current == 0, add right below it (still inside the if current == 0: block):

            conn.execute(
                "INSERT INTO welcome_template (id, content) VALUES (1, NULL) "
                "ON CONFLICT (id) DO NOTHING"
            )
  • Step 5: Run test to verify it passes

Run: pytest tests/test_welcome_template_migration.py -v Expected: PASS.

  • Step 6: Run the full DB-related test suite to check for regressions

Run: pytest tests/test_db.py tests/test_db_migrations.py -v 2>&1 | tail -30 Expected: all green (or only pre-existing skips).

  • Step 7: Commit
git add src/db.py tests/test_welcome_template_migration.py
git commit -m "feat(db): schema v15 — welcome_template singleton table"

Task 2: WelcomeTemplateRepository

Files:

  • Create: src/repositories/welcome_template.py

  • Test: extend tests/test_welcome_template_migration.py (or new file tests/test_welcome_template_repo.py)

  • Step 1: Write the failing test

Create tests/test_welcome_template_repo.py:

"""Unit tests for WelcomeTemplateRepository."""

import duckdb
import pytest

from src.db import _ensure_schema
from src.repositories.welcome_template import WelcomeTemplateRepository


@pytest.fixture
def conn(tmp_path):
    db_path = tmp_path / "system.duckdb"
    c = duckdb.connect(str(db_path))
    _ensure_schema(c)
    yield c
    c.close()


def test_get_returns_none_on_fresh_install(conn):
    repo = WelcomeTemplateRepository(conn)
    row = repo.get()
    assert row is not None
    assert row["content"] is None  # default sentinel


def test_set_stores_content(conn):
    repo = WelcomeTemplateRepository(conn)
    repo.set("Hello {{ instance.name }}", updated_by="admin@example.com")
    row = repo.get()
    assert row["content"] == "Hello {{ instance.name }}"
    assert row["updated_by"] == "admin@example.com"
    assert row["updated_at"] is not None


def test_reset_clears_content(conn):
    repo = WelcomeTemplateRepository(conn)
    repo.set("custom", updated_by="admin@example.com")
    repo.reset(updated_by="admin@example.com")
    row = repo.get()
    assert row["content"] is None
  • Step 2: Run the test to verify it fails

Run: pytest tests/test_welcome_template_repo.py -v Expected: FAIL — WelcomeTemplateRepository not importable.

  • Step 3: Implement the repository

Create src/repositories/welcome_template.py:

"""Repository for the per-instance welcome-prompt override (singleton row)."""

from datetime import datetime, timezone
from typing import Any, Optional

import duckdb


class WelcomeTemplateRepository:
    def __init__(self, conn: duckdb.DuckDBPyConnection):
        self.conn = conn

    def get(self) -> dict[str, Any]:
        """Return the singleton row. Always exists post-migration; content
        is None when no override is set (= use shipped default)."""
        row = self.conn.execute(
            "SELECT id, content, updated_at, updated_by FROM welcome_template WHERE id = 1"
        ).fetchone()
        if row is None:
            # Defensive: re-seed if a previous admin manually deleted it.
            self.conn.execute(
                "INSERT INTO welcome_template (id, content) VALUES (1, NULL) "
                "ON CONFLICT (id) DO NOTHING"
            )
            return {"id": 1, "content": None, "updated_at": None, "updated_by": None}
        return {
            "id": row[0],
            "content": row[1],
            "updated_at": row[2],
            "updated_by": row[3],
        }

    def set(self, content: str, *, updated_by: str) -> None:
        now = datetime.now(timezone.utc)
        self.conn.execute(
            """INSERT INTO welcome_template (id, content, updated_at, updated_by)
               VALUES (1, ?, ?, ?)
               ON CONFLICT (id) DO UPDATE SET
                   content = excluded.content,
                   updated_at = excluded.updated_at,
                   updated_by = excluded.updated_by""",
            [content, now, updated_by],
        )

    def reset(self, *, updated_by: str) -> None:
        """Clear override; renderer falls back to shipped default."""
        now = datetime.now(timezone.utc)
        self.conn.execute(
            """UPDATE welcome_template
               SET content = NULL, updated_at = ?, updated_by = ?
               WHERE id = 1""",
            [now, updated_by],
        )
  • Step 4: Run test to verify it passes

Run: pytest tests/test_welcome_template_repo.py -v Expected: PASS (3 tests).

  • Step 5: Commit
git add src/repositories/welcome_template.py tests/test_welcome_template_repo.py
git commit -m "feat(repo): WelcomeTemplateRepository singleton CRUD"

Task 3: Convert default template to Jinja2 + add sync_interval instance config

Files:

  • Modify: config/claude_md_template.txt

  • Modify: app/instance_config.py

  • Modify: config/instance.yaml.example

  • Step 1: Add get_sync_interval to app/instance_config.py

After get_instance_subtitle in app/instance_config.py, append:

def get_sync_interval() -> str:
    """Human-readable refresh cadence shown in the analyst welcome prompt."""
    return get_value("instance", "sync_interval", default="1 hour")
  • Step 2: Document sync_interval in config/instance.yaml.example

Find the instance: block (lines 9-15) and append a new commented line so it reads:

# --- Instance branding ---
instance:
  name: "AI Data Analyst"
  subtitle: "Your Organization"
  copyright: "Your Organization"
  # logo_svg: Full <svg> element for header logo (optional, default: Keboola logo)
  # Example: '<svg width="120" height="30" viewBox="0 0 100 30" xmlns="http://www.w3.org/2000/svg"><text y="22" font-size="24" fill="#333">Logo</text></svg>'
  # sync_interval: "1 hour"          # Cadence shown in analyst CLAUDE.md (e.g., "1 hour", "30 minutes", "daily")
  • Step 3: Rewrite config/claude_md_template.txt in Jinja2 syntax

Replace the entire contents with:

{# Default analyst-onboarding welcome prompt for "da analyst setup".
   Rendered server-side by src/welcome_template.py. Edit this file to change
   the OSS default; admins override per-instance via /admin/welcome.

   Available context (see docs/welcome-template.md for the full reference):
     instance.name, instance.subtitle
     server.url, server.hostname
     sync_interval                — string from instance.yaml
     data_source.type             — keboola | bigquery | local
     tables                       — list of {name, description, query_mode}
     metrics.count, metrics.categories
     marketplaces                 — list of {slug, name, plugins:[name]}
     user.email, user.name, user.is_admin, user.groups
     now, today                   — datetime / date string
#}
# {{ instance.name }} — AI Data Analyst

This workspace is connected to {{ server.url }}.
{% if instance.subtitle %}Operated by **{{ instance.subtitle }}**.{% endif %}

## Rules
- Before computing any business metric: run `da metrics show <category>/<name>`
- For current schema: read `data/metadata/schema.json`
- Do not use DESCRIBE/SHOW COLUMNS — read metadata files instead
- Save work output to `user/artifacts/`
- Sync data regularly with `da sync`

## Metrics Workflow
1. `da metrics list` — find the relevant metric ({{ metrics.count }} available, categories: {{ metrics.categories | join(", ") or "none yet" }})
2. `da metrics show <category>/<name>` — read SQL and business rules
3. Use the canonical SQL from the metric definition, adapt to the question
4. Never invent metric calculations — always check existing definitions first

## Data Sync
- `da sync` — download current data from server
- `da sync --docs-only` — just metadata and metrics (fast refresh)
- `da sync --upload-only` — upload sessions and local notes to server
- Data on the server refreshes every {{ sync_interval }}

## Available Datasets
{% for t in tables -%}
- `{{ t.name }}`{% if t.description %} — {{ t.description }}{% endif %}{% if t.query_mode == "remote" %} *(remote, queried on demand)*{% endif %}
{% else -%}
- _No tables registered yet — ask an admin to register tables in the dashboard._
{% endfor %}

{% if marketplaces -%}
## Plugins available to you
{% for mp in marketplaces -%}
- **{{ mp.name }}** ({{ mp.slug }}): {{ mp.plugins | map(attribute="name") | join(", ") }}
{% endfor %}
{% endif -%}

## Directory Structure
- `data/` — read-only data downloaded from server
  - `data/parquet/` — table data in Parquet format
  - `data/duckdb/` — local analytics DuckDB database
  - `data/metadata/` — profiles, schema, metrics cache
- `user/` — your workspace (persistent across syncs)
  - `user/artifacts/` — analysis outputs, reports, charts
  - `user/sessions/` — Claude Code session logs
- `.claude/CLAUDE.local.md` — your personal notes (never overwritten, uploaded on sync)

_Hello {{ user.name or user.email }} — generated {{ today }}._
  • Step 4: Verify the file is valid UTF-8 and renders structurally

Run: python -c "from jinja2 import Environment, StrictUndefined; Environment(undefined=StrictUndefined).parse(open('config/claude_md_template.txt').read())" Expected: no output, exit 0.

  • Step 5: Commit
git add config/claude_md_template.txt app/instance_config.py config/instance.yaml.example
git commit -m "feat(config): default welcome template in jinja2 + sync_interval"

Task 4: Renderer module (src/welcome_template.py)

Files:

  • Create: src/welcome_template.py

  • Test: tests/test_welcome_template_renderer.py

  • Step 1: Write the failing test

Create tests/test_welcome_template_renderer.py:

"""Unit tests for the welcome-prompt renderer."""

from pathlib import Path

import duckdb
import pytest

from src.db import _ensure_schema
from src.repositories.welcome_template import WelcomeTemplateRepository
from src.welcome_template import build_context, render_welcome


@pytest.fixture
def conn(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    db_path = tmp_path / "system.duckdb"
    c = duckdb.connect(str(db_path))
    _ensure_schema(c)
    yield c
    c.close()


def _user(email="alice@example.com"):
    return {"id": "u1", "email": email, "name": "Alice", "is_admin": False, "groups": ["Everyone"]}


def test_renders_default_when_no_override(conn):
    out = render_welcome(conn, user=_user(), server_url="https://example.com")
    assert "AI Data Analyst" in out
    assert "https://example.com" in out
    assert "Alice" in out


def test_renders_override(conn):
    WelcomeTemplateRepository(conn).set(
        "# {{ instance.name }} for {{ user.email }}",
        updated_by="admin@example.com",
    )
    out = render_welcome(conn, user=_user(), server_url="https://example.com")
    assert out.startswith("# AI Data Analyst for alice@example.com")


def test_strict_undefined_raises_on_missing_placeholder(conn):
    WelcomeTemplateRepository(conn).set(
        "{{ does_not_exist }}", updated_by="admin@example.com"
    )
    with pytest.raises(Exception) as exc_info:
        render_welcome(conn, user=_user(), server_url="https://example.com")
    assert "does_not_exist" in str(exc_info.value)


def test_context_exposes_documented_keys(conn):
    ctx = build_context(conn, user=_user(), server_url="https://example.com")
    for top in ("instance", "server", "sync_interval", "data_source",
                "tables", "metrics", "marketplaces", "user", "now", "today"):
        assert top in ctx, f"missing top-level key: {top}"
  • Step 2: Run the test to verify it fails

Run: pytest tests/test_welcome_template_renderer.py -v Expected: FAIL — src.welcome_template not importable.

  • Step 3: Implement the renderer

Create src/welcome_template.py:

"""Render the analyst-onboarding welcome prompt (CLAUDE.md).

Two layers:
  1. Template source — admin override from welcome_template.content,
     or the shipped default at config/claude_md_template.txt.
  2. Render context — built from instance config, table_registry,
     metric_definitions, and the calling user's RBAC-filtered marketplaces.

The Jinja2 environment uses StrictUndefined so that any typo in the
template raises immediately rather than rendering empty strings.
"""

from __future__ import annotations

from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any

import duckdb
from jinja2 import Environment, StrictUndefined
from urllib.parse import urlparse

from app.instance_config import (
    get_data_source_type,
    get_instance_name,
    get_instance_subtitle,
    get_sync_interval,
)
from src.marketplace_filter import resolve_allowed_plugins
from src.repositories.welcome_template import WelcomeTemplateRepository

_DEFAULT_TEMPLATE_PATH = (
    Path(__file__).resolve().parent.parent / "config" / "claude_md_template.txt"
)


def _load_default_template() -> str:
    if _DEFAULT_TEMPLATE_PATH.exists():
        return _DEFAULT_TEMPLATE_PATH.read_text(encoding="utf-8")
    # Last-resort embedded fallback if the OSS template file is missing
    # from the install (e.g., partial Docker COPY).
    return (
        "# {{ instance.name }} — AI Data Analyst\n\n"
        "This workspace is connected to {{ server.url }}.\n"
        "Data refreshes every {{ sync_interval }}.\n"
    )


def _list_tables(conn: duckdb.DuckDBPyConnection) -> list[dict[str, Any]]:
    rows = conn.execute(
        """SELECT name, description, query_mode
           FROM table_registry
           ORDER BY name"""
    ).fetchall()
    return [
        {"name": r[0], "description": r[1] or "", "query_mode": r[2] or "local"}
        for r in rows
    ]


def _metrics_summary(conn: duckdb.DuckDBPyConnection) -> dict[str, Any]:
    try:
        rows = conn.execute(
            "SELECT category, COUNT(*) FROM metric_definitions GROUP BY category"
        ).fetchall()
    except duckdb.CatalogException:
        return {"count": 0, "categories": []}
    return {
        "count": sum(r[1] for r in rows),
        "categories": sorted({r[0] for r in rows if r[0]}),
    }


def _marketplaces_for_user(
    conn: duckdb.DuckDBPyConnection, user_id: str
) -> list[dict[str, Any]]:
    """Return marketplaces with the plugins the user is allowed to see."""
    allowed = resolve_allowed_plugins(conn, user_id)  # set[str] of "<slug>/<plugin>"
    if not allowed:
        return []
    rows = conn.execute(
        """SELECT mr.id, mr.slug, mr.name, mp.name
           FROM marketplace_registry mr
           JOIN marketplace_plugins mp ON mp.marketplace_id = mr.id
           ORDER BY mr.slug, mp.name"""
    ).fetchall()
    grouped: dict[str, dict[str, Any]] = {}
    for mp_id, slug, mp_name, plugin_name in rows:
        key = f"{slug}/{plugin_name}"
        if key not in allowed:
            continue
        bucket = grouped.setdefault(
            slug, {"slug": slug, "name": mp_name, "plugins": []}
        )
        bucket["plugins"].append({"name": plugin_name})
    return list(grouped.values())


def build_context(
    conn: duckdb.DuckDBPyConnection,
    *,
    user: dict[str, Any],
    server_url: str,
) -> dict[str, Any]:
    """Compose the Jinja2 render context. Pure, no side effects."""
    now = datetime.now(timezone.utc)
    parsed = urlparse(server_url)
    return {
        "instance": {
            "name": get_instance_name(),
            "subtitle": get_instance_subtitle(),
        },
        "server": {
            "url": server_url,
            "hostname": parsed.hostname or "",
        },
        "sync_interval": get_sync_interval(),
        "data_source": {"type": get_data_source_type()},
        "tables": _list_tables(conn),
        "metrics": _metrics_summary(conn),
        "marketplaces": _marketplaces_for_user(conn, user.get("id", "")),
        "user": {
            "id": user.get("id", ""),
            "email": user.get("email", ""),
            "name": user.get("name") or "",
            "is_admin": bool(user.get("is_admin")),
            "groups": user.get("groups") or [],
        },
        "now": now,
        "today": date.today().isoformat(),
    }


def _resolve_template_source(conn: duckdb.DuckDBPyConnection) -> str:
    row = WelcomeTemplateRepository(conn).get()
    return row["content"] if row.get("content") else _load_default_template()


def render_welcome(
    conn: duckdb.DuckDBPyConnection,
    *,
    user: dict[str, Any],
    server_url: str,
) -> str:
    """Resolve the active template and render it for the given user."""
    source = _resolve_template_source(conn)
    env = Environment(undefined=StrictUndefined, autoescape=False)
    template = env.from_string(source)
    return template.render(**build_context(conn, user=user, server_url=server_url))
  • Step 4: Run test to verify it passes

Run: pytest tests/test_welcome_template_renderer.py -v Expected: PASS (4 tests).

  • Step 5: Commit
git add src/welcome_template.py tests/test_welcome_template_renderer.py
git commit -m "feat: server-side jinja2 renderer for welcome prompt"

Task 5: REST endpoints (/api/welcome + admin CRUD)

Files:

  • Create: app/api/welcome.py

  • Test: tests/test_welcome_template_api.py

  • Step 1: Write the failing endpoint tests

Create tests/test_welcome_template_api.py:

"""End-to-end tests for /api/welcome and /api/admin/welcome-template."""

from fastapi.testclient import TestClient

# Existing helpers in tests/helpers/ provide an authenticated client +
# admin client. Mirror the style used by tests/test_marketplaces_api.py.
from tests.helpers.auth import client_for_user, client_for_admin


def test_get_welcome_returns_rendered_markdown(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    client = client_for_user(email="alice@example.com")
    resp = client.get("/api/welcome", params={"server_url": "https://example.com"})
    assert resp.status_code == 200
    body = resp.json()
    assert "content" in body
    assert "AI Data Analyst" in body["content"]
    assert "https://example.com" in body["content"]


def test_get_welcome_requires_auth(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    from app.main import app
    resp = TestClient(app).get("/api/welcome", params={"server_url": "https://example.com"})
    assert resp.status_code == 401


def test_admin_can_set_and_reset_template(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    client = client_for_admin()

    # GET initial state
    r = client.get("/api/admin/welcome-template")
    assert r.status_code == 200
    assert r.json()["content"] is None
    assert r.json()["default"].startswith("{# Default")

    # PUT override
    r = client.put(
        "/api/admin/welcome-template",
        json={"content": "Hello {{ user.email }}"},
    )
    assert r.status_code == 200

    # Verify rendered output uses override
    r = client.get("/api/welcome", params={"server_url": "https://example.com"})
    assert r.json()["content"].startswith("Hello ")

    # DELETE = reset
    r = client.delete("/api/admin/welcome-template")
    assert r.status_code == 204
    r = client.get("/api/admin/welcome-template")
    assert r.json()["content"] is None


def test_non_admin_cannot_edit_template(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    client = client_for_user(email="alice@example.com")
    r = client.put("/api/admin/welcome-template", json={"content": "x"})
    assert r.status_code == 403


def test_invalid_jinja2_returns_400(tmp_path, monkeypatch):
    monkeypatch.setenv("DATA_DIR", str(tmp_path))
    client = client_for_admin()
    r = client.put(
        "/api/admin/welcome-template",
        json={"content": "{% for x in y %}"},  # unclosed
    )
    assert r.status_code == 400
    assert "syntax" in r.json()["detail"].lower()
  • Step 2: Run the tests to verify they fail

Run: pytest tests/test_welcome_template_api.py -v Expected: FAIL — /api/welcome not registered.

  • Step 3: Implement the router

Create app/api/welcome.py:

"""REST endpoints for the analyst-onboarding welcome prompt.

- GET  /api/welcome                  : render for the calling user (auth required)
- GET  /api/admin/welcome-template   : raw template + shipped default (admin)
- PUT  /api/admin/welcome-template   : set override (admin)
- DELETE /api/admin/welcome-template : reset to default (admin)
"""

from typing import Optional

import duckdb
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from jinja2 import TemplateSyntaxError
from pydantic import BaseModel, Field

from app.auth.access import require_admin
from app.auth.dependencies import _get_db, get_current_user
from src.repositories.welcome_template import WelcomeTemplateRepository
from src.welcome_template import _load_default_template, render_welcome


router = APIRouter(tags=["welcome"])


class WelcomeResponse(BaseModel):
    content: str


class TemplateGetResponse(BaseModel):
    content: Optional[str]  # None when no override is set
    default: str            # always the shipped default
    updated_at: Optional[str] = None
    updated_by: Optional[str] = None


class TemplatePutRequest(BaseModel):
    content: str = Field(..., min_length=1, max_length=200_000)


@router.get("/api/welcome", response_model=WelcomeResponse)
async def get_welcome(
    server_url: str = Query(..., description="The server URL the analyst is bootstrapping against"),
    user: dict = Depends(get_current_user),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    """Render the welcome prompt for the calling user. Returns rendered markdown."""
    try:
        rendered = render_welcome(conn, user=user, server_url=server_url)
    except TemplateSyntaxError as e:
        # Admin-saved a broken override; surface a hint rather than 500.
        raise HTTPException(
            status_code=500,
            detail=f"Welcome template has a syntax error: {e.message}. Reset via /admin/welcome.",
        )
    return WelcomeResponse(content=rendered)


@router.get("/api/admin/welcome-template", response_model=TemplateGetResponse)
async def admin_get_template(
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    row = WelcomeTemplateRepository(conn).get()
    return TemplateGetResponse(
        content=row["content"],
        default=_load_default_template(),
        updated_at=row["updated_at"].isoformat() if row["updated_at"] else None,
        updated_by=row["updated_by"],
    )


@router.put("/api/admin/welcome-template")
async def admin_put_template(
    payload: TemplatePutRequest,
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    # Validate Jinja2 syntax up front; reject bad templates with 400.
    from jinja2 import Environment, StrictUndefined
    try:
        Environment(undefined=StrictUndefined).parse(payload.content)
    except TemplateSyntaxError as e:
        raise HTTPException(status_code=400, detail=f"Jinja2 syntax error: {e.message}")
    WelcomeTemplateRepository(conn).set(payload.content, updated_by=user["email"])
    return {"status": "ok"}


@router.delete("/api/admin/welcome-template", status_code=204)
async def admin_reset_template(
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    WelcomeTemplateRepository(conn).reset(updated_by=user["email"])
    return Response(status_code=204)
  • Step 4: Register router in app/main.py

In app/main.py, alongside the other app.include_router(...) calls (around line 310-329), add:

    from app.api.welcome import router as welcome_router
    app.include_router(welcome_router)

Also add the import near the top with the other API imports if the file uses top-level imports for routers; otherwise keep the local import (match the existing pattern in that file).

  • Step 5: Run the API tests

Run: pytest tests/test_welcome_template_api.py -v Expected: PASS (5 tests). If tests/helpers/auth doesn't already expose client_for_user/client_for_admin, copy the pattern from tests/test_marketplaces_api.py (the existing auth-fixture style) into the new test file inline.

  • Step 6: Run the full test suite for regressions

Run: pytest tests/ -x -q 2>&1 | tail -20 Expected: all green.

  • Step 7: Commit
git add app/api/welcome.py app/main.py tests/test_welcome_template_api.py
git commit -m "feat(api): /api/welcome + /api/admin/welcome-template endpoints"

Task 6: Admin web UI (/admin/welcome)

Files:

  • Create: app/web/templates/admin_welcome.html

  • Modify: app/web/router.py

  • Step 1: Add the route handler

In app/web/router.py, after the existing admin_marketplaces_page handler (around line 676-683), add:

@router.get("/admin/welcome", response_class=HTMLResponse)
async def admin_welcome_page(
    request: Request,
    user: dict = Depends(require_admin),
    conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
    from src.repositories.welcome_template import WelcomeTemplateRepository
    from src.welcome_template import _load_default_template

    row = WelcomeTemplateRepository(conn).get()
    ctx = {
        "request": request,
        "user": user,
        "current": row["content"] or "",
        "default_template": _load_default_template(),
        "updated_at": row["updated_at"],
        "updated_by": row["updated_by"],
        "is_override": row["content"] is not None,
    }
    return templates.TemplateResponse(request, "admin_welcome.html", ctx)

If require_admin, _get_db, or duckdb are not already imported in this file, add the imports following the surrounding admin handlers' style (grep app/web/router.py for require_admin to confirm).

  • Step 2: Create the template

Create app/web/templates/admin_welcome.html:

{% extends "base.html" %}
{% block title %}Welcome Prompt — Admin{% endblock %}
{% block content %}
<div class="admin-page">
  <h1>Analyst Welcome Prompt</h1>
  <p class="muted">
    This is the CLAUDE.md generated for analysts when they run
    <code>da analyst setup</code>. Edit it to customize the onboarding
    instructions for this instance. Leave empty (or click <em>Reset to default</em>)
    to use the OSS-shipped default.
  </p>

  {% if is_override %}
    <p class="status">
      Overridden by <strong>{{ updated_by }}</strong> on
      {{ updated_at.strftime("%Y-%m-%d %H:%M UTC") if updated_at else "—" }}.
    </p>
  {% else %}
    <p class="status">Using shipped default.</p>
  {% endif %}

  <h2>Available placeholders</h2>
  <pre class="placeholder-cheatsheet">
{{ "{{ instance.name }}" }}                 — instance display name
{{ "{{ instance.subtitle }}" }}             — operator name
{{ "{{ server.url }}" }}                    — full server URL
{{ "{{ server.hostname }}" }}               — host part
{{ "{{ sync_interval }}" }}                 — refresh cadence (instance.yaml)
{{ "{{ data_source.type }}" }}              — keboola | bigquery | local
{{ "{{ tables }}" }}                        — list of {name, description, query_mode}
{{ "{{ metrics.count }}" }}, {{ "{{ metrics.categories }}" }}
{{ "{{ marketplaces }}" }}                  — RBAC-filtered list of {slug, name, plugins[]}
{{ "{{ user.email }}" }}, {{ "{{ user.name }}" }}, {{ "{{ user.is_admin }}" }}, {{ "{{ user.groups }}" }}
{{ "{{ now }}" }}, {{ "{{ today }}" }}
  </pre>

  <form id="welcome-form" onsubmit="return false">
    <textarea id="content" rows="30" cols="100">{{ current or default_template }}</textarea>
    <div class="actions">
      <button type="button" id="save-btn">Save override</button>
      <button type="button" id="reset-btn" class="secondary">Reset to default</button>
      <button type="button" id="preview-btn" class="secondary">Preview</button>
    </div>
    <div id="result" class="result"></div>
    <pre id="preview" class="preview" hidden></pre>
  </form>
</div>

<script>
  const $ = (id) => document.getElementById(id);
  const result = $("result");

  $("save-btn").addEventListener("click", async () => {
    result.textContent = "Saving…";
    const r = await fetch("/api/admin/welcome-template", {
      method: "PUT",
      headers: {"Content-Type": "application/json"},
      body: JSON.stringify({content: $("content").value}),
    });
    if (r.ok) {
      result.textContent = "Saved.";
    } else {
      const err = await r.json();
      result.textContent = "Error: " + (err.detail || r.statusText);
    }
  });

  $("reset-btn").addEventListener("click", async () => {
    if (!confirm("Reset to OSS default? Your override will be lost.")) return;
    const r = await fetch("/api/admin/welcome-template", {method: "DELETE"});
    if (r.ok) {
      result.textContent = "Reset. Reload to see the default.";
    } else {
      result.textContent = "Error: " + r.statusText;
    }
  });

  $("preview-btn").addEventListener("click", async () => {
    // Render against the calling admin's identity, with a placeholder URL.
    const r = await fetch("/api/welcome?server_url=" + encodeURIComponent(window.location.origin));
    if (r.ok) {
      const j = await r.json();
      $("preview").textContent = j.content;
      $("preview").hidden = false;
    } else {
      const err = await r.json();
      result.textContent = "Render error: " + (err.detail || r.statusText);
    }
  });
</script>
{% endblock %}
  • Step 3: Add a nav entry

Search for where admin_marketplaces is linked in the existing admin nav (likely app/web/templates/base.html or _app_header.html). Add a sibling link <a href="/admin/welcome">Welcome Prompt</a> under the same admin-only menu block.

grep -nE 'admin_marketplaces|admin/marketplaces' app/web/templates/*.html

Open the matched file and insert next to the existing admin-marketplaces link, copying the surrounding markup exactly.

  • Step 4: Smoke test the page
uvicorn app.main:app --reload &
SERVER_PID=$!
sleep 2
# Log in as admin in your browser, navigate to /admin/welcome — verify
# the textarea loads with the default template and Save / Reset / Preview
# all return success.
kill $SERVER_PID

If you can't run a browser interactively, at least confirm the page returns 200 for an admin and 403 for a non-admin with curl against an authenticated session cookie.

  • Step 5: Commit
git add app/web/router.py app/web/templates/admin_welcome.html app/web/templates/base.html
git commit -m "feat(web): /admin/welcome editor page"

Task 7: CLI da analyst setup fetches rendered template from server

Files:

  • Modify: cli/commands/analyst.py

  • Test: tests/test_cli_analyst_welcome.py

  • Step 1: Write the failing CLI test

Create tests/test_cli_analyst_welcome.py:

"""Integration tests for da analyst setup → /api/welcome wiring."""

from pathlib import Path

import httpx
import pytest

from cli.commands.analyst import _generate_claude_md


class _MockClient:
    def __init__(self, responses):
        self._responses = responses
        self.calls = []

    def get(self, url, headers=None, timeout=None):
        self.calls.append(url)
        body, status = self._responses.get(url, ({}, 404))
        return httpx.Response(status_code=status, json=body, request=httpx.Request("GET", url))


def test_generate_claude_md_uses_server_render(tmp_path, monkeypatch):
    workspace = tmp_path / "ws"
    (workspace / ".claude").mkdir(parents=True)
    rendered = "# CUSTOM\n\nFrom server.\n"
    mock = _MockClient({
        "https://example.com/api/welcome?server_url=https%3A%2F%2Fexample.com": (
            {"content": rendered}, 200
        ),
    })
    monkeypatch.setattr("cli.commands.analyst.httpx", type("_M", (), {"get": mock.get}))
    _generate_claude_md(workspace, server_url="https://example.com", token="t")
    assert (workspace / "CLAUDE.md").read_text(encoding="utf-8") == rendered


def test_generate_claude_md_falls_back_on_404(tmp_path, monkeypatch):
    workspace = tmp_path / "ws"
    (workspace / ".claude").mkdir(parents=True)
    mock = _MockClient({})  # everything 404s
    monkeypatch.setattr("cli.commands.analyst.httpx", type("_M", (), {"get": mock.get}))
    _generate_claude_md(workspace, server_url="https://example.com", token="t")
    body = (workspace / "CLAUDE.md").read_text(encoding="utf-8")
    assert "AI Data Analyst" in body  # embedded fallback contains this string
    assert "https://example.com" in body
  • Step 2: Run to confirm failure

Run: pytest tests/test_cli_analyst_welcome.py -v Expected: FAIL — current _generate_claude_md signature is (workspace, instance_name, server_url, sync_interval), not (workspace, server_url, token).

  • Step 3: Rewrite _generate_claude_md, drop _get_instance_name, drop --sync-interval

In cli/commands/analyst.py:

Replace the _get_instance_name function (lines 255-274) with a deletion (the function is no longer needed — server renders everything).

Replace the entire _generate_claude_md function (lines 281-323) with:

def _generate_claude_md(workspace: Path, server_url: str, token: str) -> None:
    """Fetch the rendered welcome prompt from the server and write CLAUDE.md.

    Falls back to a minimal embedded template if the server endpoint is
    unavailable (e.g., older server versions before /api/welcome shipped).
    """
    import httpx
    from urllib.parse import quote

    server_url = server_url.rstrip("/")
    headers = {"Authorization": f"Bearer {token}"}
    url = f"{server_url}/api/welcome?server_url={quote(server_url, safe='')}"

    rendered: str | None = None
    try:
        resp = httpx.get(url, headers=headers, timeout=15.0)
        if resp.status_code == 200:
            rendered = resp.json().get("content")
    except Exception:
        pass

    if rendered is None:
        # Fallback for older servers — keeps the CLI usable, just less rich.
        rendered = (
            "# AI Data Analyst\n\n"
            f"This workspace is connected to {server_url}.\n\n"
            "## Rules\n"
            "- Before computing any business metric: run `da metrics show <category>/<name>`\n"
            "- Save work output to `user/artifacts/`\n"
            "- Sync data regularly with `da sync`\n"
        )

    (workspace / "CLAUDE.md").write_text(rendered, encoding="utf-8")

    local_md = workspace / ".claude" / "CLAUDE.local.md"
    if not local_md.exists():
        local_md.write_text(
            "# My Notes\n\n"
            "Personal notes for this workspace. Uploaded to the server on `da sync --upload-only`.\n",
            encoding="utf-8",
        )

    settings_path = workspace / ".claude" / "settings.json"
    if not settings_path.exists():
        settings = {"model": "sonnet", "permissions": {"allow": ["Read", "Bash", "Grep", "Glob"]}}
        settings_path.write_text(json.dumps(settings, indent=2))

In the setup command (around line 353-394):

  • Drop the sync_interval parameter from the function signature.
  • Replace the call site at line 393-394:
    # 7. Generate CLAUDE.md (rendered server-side)
    typer.echo("Fetching welcome prompt from server...")
    _generate_claude_md(workspace, server_url, token)
  • Drop the instance_name = _get_instance_name(...) call at line 393.
  • In the summary block (line 397-406), replace f" Instance : {instance_name}" with a server-only line:
    typer.echo(f"  Server   : {server_url}")
    typer.echo(f"  Tables   : {n_downloaded} downloaded, {total_rows} total rows")
    typer.echo(f"  Workspace: {workspace}")
  • Step 4: Run the CLI tests

Run: pytest tests/test_cli_analyst_welcome.py tests/test_cli.py tests/test_analyst_bootstrap.py -v Expected: PASS. Existing analyst-bootstrap tests may have hard-coded sync_interval arguments; update them to call the new signature (or remove the arg).

If existing tests reference _get_instance_name, delete those test cases — the helper is gone.

  • Step 5: Commit
git add cli/commands/analyst.py tests/test_cli_analyst_welcome.py tests/test_cli.py tests/test_analyst_bootstrap.py
git commit -m "feat(cli): da analyst setup fetches rendered welcome from /api/welcome"

Task 8: Operator-facing docs

Files:

  • Create: docs/welcome-template.md

  • Step 1: Write the doc

Create docs/welcome-template.md:

# Welcome prompt customization

The welcome prompt is the `CLAUDE.md` file generated in an analyst's local
workspace by `da analyst setup`. It instructs Claude Code on how to behave in
that workspace — which commands to use, where to read schema metadata, what
metrics exist, what plugins are available.

## Defaults

The OSS distribution ships a generic welcome prompt at
`config/claude_md_template.txt`. Every Agnes instance starts with this default;
no admin action is required.

## Customizing per instance

Admins can override the template via:

- **Admin UI:** `/admin/welcome` — textarea editor with placeholder cheatsheet
  and live preview button. Save sends a `PUT` to `/api/admin/welcome-template`.
- **REST API:**
  - `GET /api/admin/welcome-template` — returns `{content, default, updated_at, updated_by}`. `content` is `null` when no override is set.
  - `PUT /api/admin/welcome-template` with body `{"content": "..."}` — validates Jinja2 syntax, stores the override.
  - `DELETE /api/admin/welcome-template` — clears the override; renderer falls back to the shipped default.

The override lives in `system.duckdb` (table `welcome_template`, singleton
row id=1). Resetting via the UI or `DELETE` simply NULL-s `content` — the
audit trail (`updated_at`, `updated_by`) is preserved.

## Template language

[Jinja2](https://jinja.palletsprojects.com/) with `StrictUndefined`. Any
typo in a placeholder name raises an error at render time rather than
silently emitting an empty string. Server returns HTTP 500 with a hint
pointing at `/admin/welcome`; the admin UI rejects syntax errors with HTTP
400 on save.

## Available placeholders

| Placeholder | Type | Source |
|---|---|---|
| `instance.name` | string | `instance.name` in `instance.yaml` |
| `instance.subtitle` | string | `instance.subtitle` in `instance.yaml` |
| `server.url` | string | passed by the CLI (`?server_url=` query) |
| `server.hostname` | string | parsed from `server.url` |
| `sync_interval` | string | `instance.sync_interval` in `instance.yaml` (default `"1 hour"`) |
| `data_source.type` | string | `keboola` \| `bigquery` \| `local` |
| `tables` | list | rows from `table_registry`, each `{name, description, query_mode}` |
| `metrics.count` | int | total rows in `metric_definitions` |
| `metrics.categories` | list[str] | distinct categories from `metric_definitions` |
| `marketplaces` | list | RBAC-filtered for the calling user, each `{slug, name, plugins:[{name}]}` |
| `user.email` | string | calling user |
| `user.name` | string | calling user |
| `user.is_admin` | bool | calling user |
| `user.groups` | list[str] | calling user's group names |
| `now` | datetime (UTC) | server time at render |
| `today` | string (`YYYY-MM-DD`) | server date |

## RBAC

`marketplaces` is filtered through `src.marketplace_filter.resolve_allowed_plugins`
— the same logic that gates `/marketplace.zip`. Two analysts with different
group memberships will see different plugin lists in their `CLAUDE.md`.

## Example: minimal override

```jinja2
# {{ instance.name }}

This workspace is connected to {{ server.url }}.
You have access to {{ tables | length }} dataset(s):
{% for t in tables %}
- `{{ t.name }}`{% if t.description %}: {{ t.description }}{% endif %}
{%- endfor %}

Falling back to the default

Click Reset to default in the admin UI or DELETE /api/admin/welcome-template. The shipped default is always available as response.default in the GET endpoint, so admins can copy-paste it into the editor as a starting point for a new override.


- [ ] **Step 2: Commit**

```bash
git add docs/welcome-template.md
git commit -m "docs: welcome-template customization reference"

Task 9: CHANGELOG entry

Files:

  • Modify: CHANGELOG.md

  • Step 1: Add the Unreleased entry

Open CHANGELOG.md. Find the topmost ## [Unreleased] heading (create one if missing — it sits above the latest released version). Add under ### Added:

- Customizable analyst welcome prompt (`CLAUDE.md` generated by `da analyst setup`). Default ships at `config/claude_md_template.txt` (now Jinja2 syntax). Admins override per instance via `/admin/welcome` or `PUT /api/admin/welcome-template`. New endpoint `GET /api/welcome` returns the rendered prompt for the calling user, with marketplaces filtered by RBAC. See `docs/welcome-template.md` for the full placeholder reference.
- DuckDB schema v15: `welcome_template` singleton table for the per-instance override. Auto-migration v14→v15 on first start.
- New `instance.sync_interval` setting in `instance.yaml` (default `"1 hour"`) — surfaced in the welcome prompt as `{{ sync_interval }}`.

Add under ### Changed:

- **BREAKING (CLI):** `da analyst setup` no longer accepts `--sync-interval`. The cadence shown in the analyst CLAUDE.md now comes from the server's `instance.yaml`. Operators who relied on the flag should set `instance.sync_interval` in `instance.yaml` instead.
- `da analyst setup` now fetches `CLAUDE.md` from `GET /api/welcome` instead of substituting placeholders client-side. The CLI keeps a minimal embedded fallback for older servers without the endpoint.

Add under ### Fixed:

- Pre-existing bug: `_get_instance_name` in the CLI parsed `instance_name` from `/api/health`, but `/api/health` only ever returned `{"status": "ok"}`, so the configured `instance.name` was never propagated to the analyst's `CLAUDE.md`. The new server-side render path uses `app.instance_config.get_instance_name()` directly.
  • Step 2: Verify changelog format

Run: head -40 CHANGELOG.md Expected: the new bullets appear under the topmost ## [Unreleased] heading, in the right ### Added / ### Changed / ### Fixed sections.

  • Step 3: Commit
git add CHANGELOG.md
git commit -m "docs(changelog): customizable welcome prompt"

Final integration sanity check

  • Step 1: Full test suite

Run: pytest tests/ -q 2>&1 | tail -10 Expected: all green.

  • Step 2: Manual smoke test of the live flow
# 1. Start the server
uvicorn app.main:app --reload &
SERVER_PID=$!
sleep 2

# 2. As admin, GET the raw template
curl -s -H "Authorization: Bearer $ADMIN_PAT" http://localhost:8000/api/admin/welcome-template | jq .

# 3. As any user, GET the rendered welcome
curl -s -H "Authorization: Bearer $USER_PAT" "http://localhost:8000/api/welcome?server_url=http://localhost:8000" | jq -r .content | head -30

# 4. As admin, PUT a custom override
curl -s -X PUT -H "Authorization: Bearer $ADMIN_PAT" -H "Content-Type: application/json" \
  -d '{"content":"# Custom for {{ user.email }}"}' \
  http://localhost:8000/api/admin/welcome-template

# 5. Re-render — should now show the custom content
curl -s -H "Authorization: Bearer $USER_PAT" "http://localhost:8000/api/welcome?server_url=http://localhost:8000" | jq -r .content

# 6. Reset
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_PAT" http://localhost:8000/api/admin/welcome-template

kill $SERVER_PID
  • Step 3: PR-ready check

Run: grep -niE 'foundryai|groupon|prj-grp|<private-org>' $(git diff --name-only origin/main..HEAD) Expected: no matches (vendor-agnostic OSS hygiene per CLAUDE.md).

  • Step 4: Open the PR

Standard branch flow; CHANGELOG already updated. PR title: feat: customizable analyst welcome prompt (admin UI + Jinja2).


Self-review notes

Spec coverage:

  • ✓ Default standard prompt that ships with OSS — config/claude_md_template.txt, used as fallback when DB row is NULL.
  • ✓ Per-customer customization — DB-backed override with admin UI.
  • ✓ Jinja2 templating — Environment(undefined=StrictUndefined).
  • ✓ System placeholders — documented in docs/welcome-template.md and the default template's leading comment block.

Type consistency:

  • WelcomeTemplateRepository.get always returns dict (defensive re-seed if singleton missing).
  • render_welcome(conn, *, user, server_url) -> str — keyword-only, used identically in CLI test, API endpoint, and admin web preview path.
  • build_context is the single source of the placeholder schema; tests assert all top-level keys exist so changes show up immediately.

Open questions deferred to follow-ups:

  • Per-user-group templates (different welcome for analysts vs. data scientists). Out of scope here; the current user.groups placeholder lets template authors do conditional rendering inside one template.
  • Versioning / history of overrides (current schema only retains the latest). Add a welcome_template_history table later if needed.
  • i18n / multiple languages. Punted — fold into a future welcome_template.locale column if requested.