Starter Templates
Three tiers, increasing in complexity. Each tier is a starting point — copy, then mutate to fit. The Tier 1 fixtures are mirrored inscripts/tests/fixtures/solution-dev/tier1/ and verified by npm run check-snippets so this page does not silently rot.
Future: when the
em-service-template repo lands, these tiers collapse to cookiecutter gh:emergenceai/em-service-template --no-input solution_name=<your-name> tier=<1|2|3>. The inline scaffolds here are the bridge until that ships.Every scaffold pins
em-service to v0.0.15 (the version in em-service Chart as of 2026-05-14). Major em-service version bumps trigger a fixture refresh; npm run check-snippets (CI) fails if the inline snippets drift from the canonical fixtures.- Tier 1 — Minimal API service
- Tier 2 — API + worker + own DB
- Tier 3 — Agentic A2A solution
The simplest viable solution: one HTTP service, one image, JWT validation, env-injected secrets. Maps to the Quickstart shape with auth and secrets wired in.
File tree
<solution>/
├── packages/api/
│ ├── pyproject.toml
│ └── src/api/
│ ├── __init__.py
│ ├── main.py
│ └── auth.py
├── Dockerfile
└── charts/<solution>/
├── Chart.yaml
├── values.yaml
└── values.dev.yaml
Files
[project]
name = "<solution>-api"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.30",
"python-jose[cryptography]>=3.3",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/api"]
from fastapi import Depends, FastAPI
from typing import Annotated
from .auth import current_user, project_id
app = FastAPI(title="<solution>")
@app.get("/healthz")
async def healthz() -> dict[str, str]:
return {"status": "ok"}
@app.get("/echo")
async def echo(
msg: str,
user: Annotated[dict, Depends(current_user)],
proj: Annotated[str, Depends(project_id)],
) -> dict[str, str]:
return {"echo": msg, "user_sub": user["sub"], "project_id": proj}
import os
import time
from typing import Annotated
import httpx
from fastapi import Header, HTTPException, status
from jose import jwt, JWTError
KEYCLOAK_ISSUER_URL = os.environ["KEYCLOAK_ISSUER_URL"]
KEYCLOAK_AUDIENCE = os.environ["KEYCLOAK_AUDIENCE"]
# JWKS TTL — Keycloak rotates signing keys (default ~12h)
_JWKS_TTL_SECONDS = 600
_jwks_cache: dict | None = None
_jwks_cached_at: float = 0.0
def _jwks() -> dict:
global _jwks_cache, _jwks_cached_at
now = time.monotonic()
if _jwks_cache is None or now - _jwks_cached_at > _JWKS_TTL_SECONDS:
url = f"{KEYCLOAK_ISSUER_URL.rstrip('/')}/protocol/openid-connect/certs"
_jwks_cache = httpx.get(url, timeout=5.0).raise_for_status().json()
_jwks_cached_at = now
return _jwks_cache
def current_user(authorization: Annotated[str | None, Header()] = None) -> dict:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
try:
return jwt.decode(
authorization.split(" ", 1)[1],
_jwks(),
algorithms=["RS256"],
audience=KEYCLOAK_AUDIENCE,
issuer=KEYCLOAK_ISSUER_URL,
)
except JWTError as e:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}")
def project_id(x_project_id: Annotated[str | None, Header()] = None) -> str:
if not x_project_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Missing X-Project-ID header")
return x_project_id
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY packages/api/pyproject.toml packages/api/pyproject.toml
COPY packages/api/src packages/api/src
RUN uv pip install --system --no-cache-dir ./packages/api
FROM python:3.12-slim
COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn
EXPOSE 8000
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
apiVersion: v2
name: <solution>
description: A minimal CRAFT solution
type: application
version: 0.1.0
appVersion: "0.1.0"
dependencies:
- name: em-service
alias: api
version: 0.0.15
repository: oci://ghcr.io/emergenceai/em-charts
api:
fullnameOverride: <solution>-api
image:
repository: <your-registry>/<solution>-api
tag: 0.1.0
service:
type: ClusterIP
port: 8000
env:
LOG_LEVEL: "INFO"
KEYCLOAK_ISSUER_URL: "https://keycloak.example.com/realms/acme"
KEYCLOAK_AUDIENCE: "<solution>-api"
readinessProbe: { httpGet: { path: /healthz, port: 8000 } }
livenessProbe: { httpGet: { path: /healthz, port: 8000 } }
api:
image: { tag: dev }
replicaCount: 1
env:
LOG_LEVEL: "DEBUG"
KEYCLOAK_ISSUER_URL: "http://localhost:8080/realms/dev"
Mirrored fixtures
Canonical copies of the eight files above live atscripts/tests/fixtures/solution-dev/tier1/. npm run check-snippets compares the inline snippets to the fixtures and fails if they drift.Adds a worker component, a Postgres database, and Alembic migrations. Mirrors the Data Governance (em-data-readiness) shape.Below is the diff from Tier 1 — apply on top of Tier 1 to get Tier 2.Reference implementation: see Data Governance overview — the
File tree (additions)
<solution>/
├── packages/
│ ├── api/ (unchanged from Tier 1)
│ ├── worker/
│ │ ├── pyproject.toml
│ │ └── src/worker/
│ │ ├── __init__.py
│ │ └── main.py
│ └── common/
│ ├── pyproject.toml
│ └── src/common/
│ ├── __init__.py
│ ├── db.py
│ └── models.py
├── alembic.ini
├── alembic/
│ ├── env.py
│ └── versions/
│ └── 0001_initial.py
└── charts/<solution>/
├── Chart.yaml (add em-service alias for worker)
└── values.yaml (add worker block + DATABASE_URL secret)
Chart.yaml diff
charts/<solution>/Chart.yaml
apiVersion: v2
name: <solution>
type: application
version: 0.1.0
dependencies:
- name: em-service
alias: api
version: 0.0.15
repository: oci://ghcr.io/emergenceai/em-charts
# NEW for Tier 2
- name: em-service
alias: worker
version: 0.0.15
repository: oci://ghcr.io/emergenceai/em-charts
values.yaml diff
charts/<solution>/values.yaml
api:
# ... existing Tier 1 api block ...
envVars:
# NEW: DB connection string from secret
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: <solution>-secrets
key: database-url
# NEW for Tier 2
worker:
image:
repository: <your-registry>/<solution>-worker
tag: 0.1.0
service:
enabled: false
env:
LOG_LEVEL: "INFO"
envVars:
- name: DATABASE_URL
valueFrom: { secretKeyRef: { name: <solution>-secrets, key: database-url } }
Worker entry point
packages/worker/src/worker/main.py
import asyncio
import logging
import os
from common.db import session_factory
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger("worker")
async def process_one() -> None:
async with session_factory() as session:
# ... pull a job, run it, commit ...
log.info("processed job")
async def main() -> None:
while True:
try:
await process_one()
except Exception:
log.exception("worker iteration failed")
await asyncio.sleep(1.0)
if __name__ == "__main__":
asyncio.run(main())
Common DB module
packages/common/src/common/db.py
import os
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
engine = create_async_engine(os.environ["DATABASE_URL"], echo=False, future=True)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
em-data-readiness repo follows this exact pattern.Adds an A2A agent component with LiteLLM and (optionally) a Prefect flow. Mirrors the Data Insights (em-talk2data) shape.Below is the diff from Tier 2.
File tree (additions)
<solution>/
├── packages/
│ ├── api/ (unchanged)
│ ├── worker/ (unchanged)
│ ├── common/ (unchanged)
│ ├── agent/
│ │ ├── pyproject.toml
│ │ └── src/agent/
│ │ ├── __init__.py
│ │ ├── card.py # Agent Card author
│ │ ├── llm.py # LiteLLM client wrapper
│ │ └── server.py # A2A protocol server
│ └── flows/ # optional Prefect flows
│ ├── pyproject.toml
│ └── src/flows/
│ ├── __init__.py
│ └── pipeline.py
└── charts/<solution>/
├── Chart.yaml (add em-service alias for agent + optional flows)
└── values.yaml (add agent block with LLM env)
Chart.yaml diff
charts/<solution>/Chart.yaml
dependencies:
- { name: em-service, alias: api, version: 0.0.15, repository: oci://ghcr.io/emergenceai/em-charts }
- { name: em-service, alias: worker, version: 0.0.15, repository: oci://ghcr.io/emergenceai/em-charts }
# NEW for Tier 3
- { name: em-service, alias: agent, version: 0.0.15, repository: oci://ghcr.io/emergenceai/em-charts }
values.yaml additions for the agent
charts/<solution>/values.yaml
agent:
image:
repository: <your-registry>/<solution>-agent
tag: 0.1.0
service:
type: ClusterIP
port: 8001
env:
LOG_LEVEL: "INFO"
AGENT_NAME: "<solution>-agent"
LLM_GATEWAY_URL: "https://litellm.example.com/v1"
LLM_DEFAULT_MODEL: "gemini-3.5-flash"
LANGFUSE_HOST: "https://langfuse.example.com"
envVars:
- name: LLM_GATEWAY_API_KEY
valueFrom: { secretKeyRef: { name: <solution>-secrets, key: llm-gateway-api-key } }
- name: LANGFUSE_PUBLIC_KEY
valueFrom: { secretKeyRef: { name: <solution>-secrets, key: langfuse-public-key } }
- name: LANGFUSE_SECRET_KEY
valueFrom: { secretKeyRef: { name: <solution>-secrets, key: langfuse-secret-key } }
readinessProbe: { httpGet: { path: /.well-known/agent-card.json, port: 8001 } }
livenessProbe: { httpGet: { path: /.well-known/agent-card.json, port: 8001 } }
Agent Card
packages/agent/src/agent/card.py
AGENT_CARD = {
"name": "<solution>-agent",
"description": "<one-line description>",
"version": "0.1.0",
"url": "https://<solution>-agent.example.com",
"protocolVersion": "0.3",
"capabilities": {"streaming": True, "pushNotifications": False},
"skills": [
{
"id": "answer",
"name": "Answer questions",
"description": "Answers questions using the project's data",
"tags": ["qa"],
"examples": ["What's the trend in last quarter's sales?"],
}
],
"securitySchemes": {"bearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}},
"security": [{"bearer": []}],
}
Register the agent
Follow Register Your First Agent — the agent must be registered with Assets after first deployment so other services and the UI can discover it.Reference implementation
em-talk2data is the canonical reference — see Data Insights overview for the full architecture (gateway service + insights agent + text2sql agent + LiteLLM via the same gateway pattern shown above).Snippet drift check
The Tier 1 fixture files atscripts/tests/fixtures/solution-dev/tier1/ are the source of truth for the inline snippets above. Run npm run check-snippets to verify they match — CI runs this check on every PR.
If you change Tier 1 inline content, update the fixtures (or vice versa), then re-run npm run check-snippets.
Next steps
Quickstart
Walk through Tier 1 step by step.
Package and deploy
Multi-component packaging and per-environment overlays.
Register your first agent
Required for Tier 3 agents to be discoverable.
Data Insights overview
The Tier 3 reference solution in production.

