Skip to content

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:

  1. Confirm before writing (priority 10). Never call register_product or update_stock without first confirming the exact details with the owner in the conversation.
  2. 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.
  3. 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.
  4. 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:

GET /api/v1/store/insights?days=30

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:

  1. Store name — non-secret context the copilot greets the owner with (store_name).
  2. What do you sell? — free-text description of the shop (store_sells).
  3. 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 DomainTemplate field 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) to DomainTemplate.identifier_keys so invented IDs get flagged.
  • Standalone real-backend tool wiring: examples/sre-tools/ (a separate reference for connecting tools to Postgres / AWS / Loki).