Guide: Store Copilot Starter — From Demo Data to a Real Store DB¶
Audience: an engineer who knows Python but is new to symfonic. This
guide walks from symfonic init --components ...,store-starter to a
convenience-store sales & inventory copilot talking to your real store
database. Read the templated sources under
src/symfonic/cli/templates/app/domains/store/ alongside.
1. What ships out of the box¶
The Store Copilot is a ready-to-adopt reference domain: an assistant
that helps a shop owner register products, track stock, spot sales
trends, and read charts. StoreDomainPlugin bundles a persona +
guardrails, and eight tools are wired at construction time.
symfonic init my-store --components fastapi,webapp,docker-compose,auth,store-starter
drops the scaffolding at app/domains/store/:
| File | Role |
|---|---|
plugin.py |
StoreDomainPlugin — the "Store Copilot" persona + the CorePreference guardrails + the per-turn system prompt. |
tools.py |
The eight StructuredTools (TOOLS) wired into the agent at construction time. |
demo_data.py |
The deterministic seeded demo dataset (~36 SKUs, ~90 days) and the analytics functions. This is the single wiring point to swap for a real store DB. |
router.py |
GET /api/v1/store/insights — the read-only feed the Insights dashboard renders. |
seed_dev_memory.py |
Seeds the agent's identity + a couple of skills/guardrail nodes so Mind's Eye isn't empty. |
store-starter is opt-in and mutually exclusive with the
generic starter domain: when it is selected, app/domains/starter/ is
omitted and app/domains/store/ ships instead — you get exactly one
domain package.
The eight tools¶
Each tool has a pydantic input model and returns a plain,
JSON-serialisable dict the model can summarise. Six are read-only
(analytics + lookup); two mutate stock and are gated behind
confirm-before-write.
| Tool | Purpose | Kind |
|---|---|---|
top_sellers |
Best-selling products over a window (units + revenue). | read |
sales_trend |
Revenue by day or by category. | read |
sales_chart |
Chart-ready series for the chat UI / dashboard (day, category, or low_stock). |
read |
revenue_summary |
Total / average-daily / best-day revenue for a window. | read |
product_lookup |
Find a product's price, on-hand stock, and reorder status. | read |
list_low_stock |
Products at or below their reorder level. | read |
register_product |
Add a product to inventory (confirm first). | write |
update_stock |
Adjust on-hand stock — delivery or correction (confirm first). | write |
Tools operate on the seeded demo dataset out of the box, so the agent has real numbers to reason over on first run. The read tools are pure; the write tools mutate an in-process overlay that resets on restart (the documented, demo-only persistence — real persistence is the DSN path).
Charts¶
sales_chart returns {chart_type, title, labels, values, unit}. The
system prompt instructs the model to emit that JSON verbatim inside a
```chart fenced code block; the webapp renders that block as a real
chart rather than a markdown table. group_by picks the shape:
day → revenue line over time, category → revenue-by-category bar,
low_stock → on-hand units for products at/below reorder level.
The guardrails (CorePreferences)¶
Defined in app/domains/store/plugin.py:
- Confirm before writing (priority 10). Never call
register_productorupdate_stockwithout first confirming the exact details with the owner in the conversation. - Read-only external DB (priority 10). Treat any connected store database as READ-ONLY — never issue writes/updates/deletes against the owner's sales database.
- Cite the numbers (priority 8). When stating a sales figure or trend, cite the window and the units/revenue behind it from the sales tools — never invent numbers.
- Flag low stock (priority 6). When stock is at/below reorder level, proactively flag it and suggest a reorder quantity.
Priority 10 = hard rails: MetacognitiveMiddleware refuses any tool
call that would cross them. Priority 6–8 = soft guidance. As a
belt-and-braces backstop, validate_state_transition also hard-blocks
tool names like db_write / db_delete / db_drop that should never
exist against a customer's database.
2. The seeded demo dataset¶
demo_data.py is the "sample store" that ships with the scaffold:
~36 SKUs across six real corner-store categories (Beverages, Snacks,
Tobacco, Household, Fresh, Personal Care) and ~90 days of daily sales
with believable shape — weekend spikes on impulse categories, one
trending-up SKU (energy drinks), one declining SKU (cigarettes), and a
couple of intentionally low-stock items so list_low_stock has hits.
The data is generated deterministically from a fixed seed, so every run of the demo tells the same story — charts and trend answers are stable and testable. Dates are anchored to today, so the 90-day window is always "recent".
3. Wiring to a real store database¶
demo_data.py has exactly two entry points to replace:
get_products()— the product catalog._sales_rows(days)— per-day sales rows.
Point those at reads from your real store database (or the read-only
DSN the owner supplies at onboarding, store_profile.sales_dsn) and
everything above keeps working unchanged: the analytics functions
(top_sellers, sales_trend, category_breakdown,
revenue_summary, list_low_stock, insights_summary), all eight
tools, and the /api/v1/store/insights endpoint all read through those
two functions.
Keep register_product / update_stock gated behind the
"confirm before writing" CorePreference, and honour the read-only
guardrail — if you connect the owner's live sales DB, connect with a
read-only role so a bug can never mutate their data.
4. The Insights dashboard¶
app/domains/store/router.py exposes:
It is authenticated (get_current_user), read-only, and never mutates
inventory. The payload (demo_data.insights_summary) carries totals,
top sellers, a daily-revenue line series, a revenue-by-category
breakdown, and current low-stock items — the same analytics the agent's
tools use, served as one JSON blob. The Insights page
(webapp/src/pages/Insights.tsx) consumes it and draws the charts.
Swap demo_data for your real store DB and this endpoint serves live
numbers with no other change.
5. Store onboarding¶
When store-starter is combined with onboarding + auth, the
onboarding form (webapp/src/pages/Onboarding.tsx, backed by
app/onboarding/router.py) asks three things:
- Store name — non-secret context the copilot greets the owner
with (
store_name). - What do you sell? — free-text description of the shop
(
store_sells). - Connect my store's sales database (read-only) — an optional
read-only DSN (
sales_dsn).
Security note: the raw DSN is deliberately never persisted to memory — only the non-secret store context (name, what it sells) and the fact that a live source exists are stored. This keeps a connection secret out of the MEMORY_CONTEXT the model reads every turn.
6. Adding a 9th tool¶
Walk-through: a restock_suggestion tool that recommends order
quantities. Same shape as the existing read tools — a pydantic input
model, a function returning a plain dict, wrapped as a
StructuredTool.
6.1 Define the function + schema¶
# app/domains/store/tools.py (add next to the other tools)
from pydantic import BaseModel, Field
class RestockInput(BaseModel):
days: int = Field(default=30, ge=1, le=365, description="Look-back window.")
cover_days: int = Field(default=14, ge=1, le=90,
description="Days of cover to target.")
def _restock_suggestion(days: int = 30, cover_days: int = 14) -> dict:
"""Suggest reorder quantities for low-stock items based on run-rate."""
low = demo_data.list_low_stock()
# ... compute a suggested quantity per SKU from recent sales run-rate ...
return {"window_days": days, "cover_days": cover_days, "items": low}
6.2 Register it in TOOLS¶
# inside _build_tools() in app/domains/store/tools.py
StructuredTool.from_function(
func=_restock_suggestion,
name="restock_suggestion",
description="Suggest reorder quantities for low-stock items based on "
"recent sales run-rate.",
args_schema=RestockInput,
),
Tools must be present at construction time — the AgentGraph
ToolRegistry freezes at compile(), which is why store-starter
passes SymfonicAgent(tools=TOOLS) (and get_domain_tools() returns
an empty list). See 06-lazy-tooling.md for
prompt-time tool selection.
6.3 Update DomainTemplate.tool_manifest¶
The HMS system prompt reads the manifest to tell the LLM which tools exist. Forget this and the model hallucinates tool names or narrates tool use in text.
# app/domains/store/plugin.py — STORE_DOMAIN.tool_manifest
tool_manifest=[
"top_sellers: Best-selling products over a window.",
# ... the existing seven ...
# NEW:
"restock_suggestion: Suggest reorder quantities for low-stock items.",
],
6.4 Add a CorePreference if relevant¶
If the new tool crosses a safety boundary, append a CorePreference
to STORE_DOMAIN. Priority 10 → hard refusal; 5–8 → soft guidance.
References¶
- Feature Catalog § Domain Plugins —
every
DomainTemplatefield with opt-in example. - Scaffolding:
symfonic init my-store --components fastapi,webapp,docker-compose,auth,store-starter. - Templated sources:
src/symfonic/cli/templates/app/domains/store/. - Production patterns:
05-production.md. - Lazy tooling:
06-lazy-tooling.md. - Intent + tool routing:
07-intent-and-tool-routing.md. - Fabrication detection:
08-fabrication-detection.md— add store-specific identifier keys (e.g.sku) toDomainTemplate.identifier_keysso invented IDs get flagged. - Standalone real-backend tool wiring:
examples/sre-tools/(a separate reference for connecting tools to Postgres / AWS / Loki).