Level 4: Plugins and Domain Customization¶
Plugins are how you inject business-specific behavior into symfonic without modifying the framework. This level covers DomainTemplate configuration, the BaseDomainPlugin protocol, guardrails, and SOUL schema integration.
Prerequisites¶
- Completed Level 3
- A working agent with memory
What is a DomainTemplate?¶
A DomainTemplate is a frozen Pydantic model that declares what your
domain needs. The framework stays generic; all business-specific
configuration lives in the template.
DomainTemplate Fields¶
| Field | Type | Purpose |
|---|---|---|
name |
str |
Human-readable domain identifier |
description |
str |
Domain directives rendered into both HMS and JIT prompts (v7.0.2, default empty) |
required_labels |
list[str] |
Graph labels the domain expects to exist |
onboarding_checklist |
list[str] |
Items to collect during initial conversations |
soul_schema |
dict[str, str] |
Schema for the SOUL label (field name to type) |
tool_manifest |
list[str] |
Short tool descriptions injected into system prompt |
tool_catalog |
dict |
Full JSON schemas for JIT tool hydration |
tool_trigger_keywords |
dict[str, list[str]] |
Per-tool keyword gating for in-prompt manifest and v7.0.1 wire-level routing (v6.1.8, default empty) |
identifier_keys |
list[str] |
Extra keys flagged by the fabrication detector (v7.0, default empty) |
core_preferences |
list[CorePreference] |
Guardrails consulted by MetacognitiveMiddleware (priority 10 = hard refusal; 5-7 = soft guidance) |
monitoring |
MonitoringBlueprint |
Anomaly detection thresholds and intervals |
descriptioncaps. Rendered into both prompts. Truncated perFrameworkConfig.domain_description_max_chars(default(2000, 400)for(full_hms, jit)) with a single WARNING log per agent lifetime. Raise the cap viadomain_description_max_chars=(5000, 800)if needed — the JIT prompt will grow in proportion.
Creating a Custom Domain¶
Here is a legal assistant domain:
from symfonic.agent.domain import DomainTemplate
LEGAL_DOMAIN = DomainTemplate(
name="legal-assistant",
required_labels=["SOUL", "CASE_LAW", "CLIENT_PROFILE", "STATUTE"],
onboarding_checklist=[
"Client name and contact",
"Jurisdiction (federal/state)",
"Legal specialty area",
"Active case numbers",
],
soul_schema={
"name": "str",
"jurisdiction": "str",
"specialty": "str",
"communication_style": "str",
"risk_tolerance": "str",
},
tool_manifest=[
"search_case_law: Search case law database by citation or topic",
"generate_contract: Generate a contract from a template",
"check_statute: Look up statute text by code and section",
],
)
Pass it to FrameworkConfig:
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.engine import SymfonicAgent
config = FrameworkConfig(domain=LEGAL_DOMAIN)
agent = SymfonicAgent(model_provider=provider, config=config)
The framework uses the template to:
- Flag missing graph labels as pending in DiscoveryService
- Inject
onboarding_checklistitems into the HMS prompt - Map
soul_schemafields into the SOUL extraction logic - Include
tool_manifestin the system prompt so the LLM knows available tools
Lazy tooling and
tool_manifest: Whenlazy_tooling=Trueandenable_hms_prompt=True, the engine uses the procedural memory layer to select only the 3–5 tools relevant to each query instead of injecting all tool schemas on every request. A populatedtool_manifestis one of the three mandatory prerequisites for this to work. See Guide 6: Lazy Tooling for the full activation recipe.
The BaseDomainPlugin Protocol¶
A domain plugin is any class implementing three required methods (plus one optional contribution hook added in 7.4):
class BaseDomainPlugin:
name: str
async def inject_system_prompt(self, state: dict) -> str:
"""Return domain-specific system prompt text.
Called before each LLM invocation. The state dict contains
TENANT_ID and other context from the current scope.
"""
...
async def validate_state_transition(self, action: str, context: dict) -> bool:
"""Return False to block unsafe actions.
Called before any tool execution or state change.
Return True to allow, False to block.
"""
...
def get_domain_tools(self) -> list:
"""Return LangChain StructuredTools for this domain.
IMPORTANT: Tools must be passed at agent construction time
via SymfonicAgent(tools=[...]). If this method returns tools,
load_plugin() will raise SymfonicAgentError because the
ToolRegistry freezes after graph compilation.
"""
...
Contribution positions (cached vs volatile, 7.4+)¶
BaseDomainPlugin plugins may ALSO define an optional inject_contributions
hook that returns list[PluginContribution]. Each contribution declares its
cache position:
position="cached"(default) — session-stable content. Lands in the cached L1 prefix so the Anthropic prompt cache can warm it once and reuse it on every subsequent turn.position="volatile"— per-turn, query-conditioned content (vector search hits, RAG snippets, live counters). Lands in L2, AFTER thecache_controlbreakpoint, so it never invalidates the cached prefix.
from symfonic.core.plugins import PluginContribution
class QueryAwarePlugin:
name = "QueryAware"
async def inject_contributions(self, state):
# Session-stable persona — cache it.
cached = PluginContribution(
content="You are the query-aware support agent.",
position="cached",
)
# Per-turn RAG block — volatile, sits outside the cached prefix.
query = state.get("QUERY", "")
snippets = await self._vector_search(query)
volatile = PluginContribution(
content=f"Relevant docs for: {query}\n{snippets}",
position="volatile",
)
return [cached, volatile]
async def inject_system_prompt(self, state):
# 7.4: when inject_contributions is defined, the engine SKIPS
# this method. Leave it as a stub for Protocol compliance.
return ""
async def validate_state_transition(self, action, ctx): return True
def get_domain_tools(self): return []
Migration from inject_system_prompt¶
The 7.4 engine dispatches per plugin:
- If the plugin defines
inject_contributions, that method is called and itslist[PluginContribution]is rendered into the prompt slots. The legacyinject_system_promptis NOT called for this plugin (no double-emit). - If the plugin does NOT define
inject_contributions, the engine falls back toinject_system_promptand synthesises a singlePluginContribution(content=..., position="cached")so downstream rendering uses one code path.
Every existing 7.3.x plugin keeps working unchanged. Plugins that opt into
inject_contributions MUST keep inject_system_prompt as at least a
return "" stub (the runtime-checkable BaseDomainPlugin Protocol requires
the method name to exist; an empty body is sufficient).
Cache benefit caveats¶
- The cache benefit on
position="volatile"requiresFrameworkConfig.prompt_layer_mode="stratigraphic". On the JIT path (context_strategy="jit"or legacyjit_context=True), volatile contributions are appended to the flat prompt body and provide no cache improvement; the engine emits a one-time WARNING per agent instance the first time this is observed. position="kernel"is reserved for a future redesign (L0 injection) and is accepted-but-no-op in 7.4: the engine remaps toposition="cached"with a one-time WARNING.
Extraction protocol layout (7.4+)¶
In stratigraphic mode the framework now splits the built-in extraction protocol the same way it splits plugin contributions:
- The STABLE surface (JSON schema,
LABEL FIELDrules, few-shot example, domain-specific labels, constraints) lives in L1 alongside the cached plugin contributions, so it warms with the Anthropic prompt cache. - The VOLATILE
KNOWN ENTITIEShint (per-turn entity ids surfaced by the hydration pipeline) lives in L2 after the cache breakpoint, next to the memory-context block that lists the same activated nodes.
This is transparent to plugin authors: the existing
MemoryExtractionSection.render(state) method still returns the joined
string for legacy callers, and the new render_split(state) method
returns the (stable, volatile) tuple the engine uses in stratigraphic
mode. No FrameworkConfig knob: legacy mode is byte-for-byte unchanged,
and stratigraphic mode always benefits from the split.
Multi-section composition¶
A plugin can return MULTIPLE contributions per turn. The engine groups by
position, sorts by (order, registration_index) (lower order wins, stable
sort), and renders each contribution's content under a ### {plugin.name}
header on its first appearance per (plugin, position) group. Pass
header="" to suppress the auto-header (the plugin owns its own scoping),
or header="## My Heading" to use a verbatim heading.
async def inject_contributions(self, state):
return [
PluginContribution(content="Sectional persona", position="cached", order=10),
PluginContribution(content="Quick reference card", position="cached", order=20),
PluginContribution(content="Live RAG hits", position="volatile"),
]
Example: Store Growth Plugin¶
A domain plugin bundles a system-prompt contribution, action guardrails, and domain tools. This illustrative plugin advises a small online store — pricing rules, listing hygiene, and a profit calculator tool:
from symfonic.core.contracts.types import PromptState
class StoreGrowthPlugin:
name = "Store Growth"
def __init__(self, monthly_fixed_costs: float = 0.0) -> None:
self._fixed_costs = monthly_fixed_costs
self._calculator = ProfitCalculator()
async def inject_system_prompt(self, state: PromptState) -> str:
tenant = state.get("TENANT_ID", "store")
return f"""You are a growth assistant for the {tenant} online store.
**Listing rules:**
- Titles: Brand + Key Feature + Product Type + Size/Color (max 200 chars)
- Keep descriptions factual; no unverifiable claims
**Pricing:**
- Target margin stays above the product's landed cost
- Prefer promotions over permanent markdowns
**Guardrails:**
- NEVER recommend pricing below cost + fees + 15% margin minimum
- NEVER suggest manipulating reviews or rankings"""
async def validate_state_transition(self, action: str, context: dict) -> bool:
blocked = {
"delete_listing",
"remove_product",
"price_below_cost",
"manipulate_reviews",
"fake_orders",
}
return action not in blocked
def get_domain_tools(self) -> list:
# Returns a StructuredTool wrapping ProfitCalculator.calculate()
...
Real Example: Mexican Criminal Law Plugin¶
This plugin implements a CNPP procedure state machine with strict stage
ordering. It demonstrates how validate_state_transition can enforce
complex domain rules:
from enum import Enum
class EtapaProcesal(str, Enum):
INVESTIGACION_INICIAL = "investigacion_inicial"
INVESTIGACION_COMPLEMENTARIA = "investigacion_complementaria"
INTERMEDIA = "intermedia"
JUICIO_ORAL = "juicio_oral"
EJECUCION = "ejecucion"
class PenalMexicanoPlugin:
name = "Derecho Penal MX"
def __init__(self, initial_stage=EtapaProcesal.INVESTIGACION_INICIAL):
self._state_machine = ProcedureStateMachine(initial=initial_stage)
async def inject_system_prompt(self, state) -> str:
stage_info = self._state_machine.get_stage_info()
return f"""Eres un asistente legal especializado en Derecho Penal Mexicano.
**Etapa Procesal Actual:** {stage_info['description']}
**Articulos CNPP Relevantes:** {stage_info['cnpp_articles']}
**Guardrails:**
- NUNCA dar consejos que impliquen evadir la justicia
- SIEMPRE recomendar la asistencia de un abogado certificado"""
async def validate_state_transition(self, action: str, context: dict) -> bool:
# Block ethical violations unconditionally
blocked = {"obstruct_justice", "hide_evidence", "witness_tampering"}
if action in blocked:
return False
# Validate procedural stage transitions
if action.startswith("transition_to_"):
target_name = action[len("transition_to_"):]
try:
target = EtapaProcesal(target_name)
except ValueError:
return False
return self._state_machine.can_transition(target)
return True
def get_domain_tools(self) -> list:
# Returns a procedure_status tool
# See examples/plugins/penal_mexicano.py for full implementation
...
The full implementation is in examples/plugins/penal_mexicano.py.
Loading Plugins¶
There is a critical constraint: domain tools must be registered at
construction time. The AgentGraph's ToolRegistry freezes after
compile() (called eagerly in AgentRuntime.__init__), so load_plugin()
raises SymfonicAgentError if the plugin returns tools.
The correct pattern:
from myapp.plugins import StoreGrowthPlugin
# 1. Create plugin and extract tools
plugin = StoreGrowthPlugin(monthly_fixed_costs=500.0)
domain_tools = plugin.get_domain_tools()
# 2. Pass tools at construction time
agent = SymfonicAgent(
model_provider=provider,
tools=domain_tools, # tools go here -- before compile()
config=FrameworkConfig(domain=MY_DOMAIN, enable_hms_prompt=True),
)
# 3. Load plugin for prompt injection + guardrails
agent.load_plugin(plugin) # only registers prompt + validation hooks
Multiple plugins can be loaded. Their system prompt contributions are chained in registration order.
Guardrails: validate_action()¶
After loading plugins, use validate_action() to check whether an
action is allowed before executing it:
# Single plugin check (all plugins must agree)
allowed = await agent.validate_action("delete_listing", {"tenant": "acme"})
if not allowed:
print("Action blocked by domain guardrails")
# Use in tool execution flow
async def safe_execute(agent, action, context, params):
if not await agent.validate_action(action, context):
return {"error": f"Action '{action}' blocked by domain policy"}
return await execute_tool(action, params)
Plugin validation errors are non-fatal -- if a plugin's
validate_state_transition raises an exception, it defaults to allowing
the action (fail-open for availability).
SOUL Schema Integration¶
The soul_schema field in DomainTemplate defines what the agent should
learn about each tenant. During consolidation, the HMS extracts SOUL
facts from conversations and stores them as graph nodes.
ECOMMERCE_DOMAIN = DomainTemplate(
name="ecommerce",
soul_schema={
"brand_voice": "str",
"target_audience": "str",
"competitive_edge": "str",
"monthly_revenue": "str",
"primary_marketplace": "str",
},
# ...
)
These fields are injected into the HMS system prompt. The LLM learns to extract them from conversations:
User: "Our brand targets young professionals aged 25-35 who value quality."
Agent: (extracts and stores SOUL: target_audience = "young professionals 25-35")
Query SOUL data via label_prefix:
soul_nodes = await graph_store.query_nodes(
scope,
label_prefix="SOUL",
)
for node in soul_nodes:
print(f"{node.label}: {node.content}")
Full Example: Domain + Plugin + Agent¶
Putting it all together:
import asyncio
from symfonic.agent.config import FrameworkConfig
from symfonic.agent.domain import DomainTemplate
from symfonic.agent.engine import SymfonicAgent
from symfonic.agent.types import FrameworkTenantScope
from symfonic.core.providers import AnthropicProvider
# 1. Define the domain
MY_DOMAIN = DomainTemplate(
name="customer-success",
required_labels=["SOUL", "ACCOUNT", "TICKET", "PRODUCT_FEEDBACK"],
onboarding_checklist=[
"Company name",
"Account tier (free/pro/enterprise)",
"Primary contact",
"Key pain points",
],
soul_schema={
"company_name": "str",
"account_tier": "str",
"primary_contact": "str",
"satisfaction_score": "str",
},
tool_manifest=[
"search_tickets: Search support tickets by keyword or status",
"escalate_ticket: Escalate a ticket to engineering",
"get_account_health: Retrieve account health metrics",
],
)
# 2. Define the plugin
class CustomerSuccessPlugin:
name = "Customer Success"
async def inject_system_prompt(self, state) -> str:
return """You are a Customer Success agent.
- Prioritize customer retention and satisfaction
- Escalate P0/P1 issues immediately
- Never promise features not on the roadmap
- Always check account health before making recommendations"""
async def validate_state_transition(self, action, context) -> bool:
blocked = {"delete_account", "refund_without_approval", "share_internal_data"}
return action not in blocked
def get_domain_tools(self) -> list:
return [] # tools passed at construction time
# 3. Wire everything together
async def main():
plugin = CustomerSuccessPlugin()
agent = SymfonicAgent(
model_provider=AnthropicProvider(),
config=FrameworkConfig(
domain=MY_DOMAIN,
auto_hydrate=True,
auto_consolidate=True,
enable_hms_prompt=True,
),
)
agent.load_plugin(plugin)
scope = FrameworkTenantScope(tenant_id="customer-acme")
response = await agent.run(
"Our company is TechCorp, we're on the enterprise tier, "
"and our main pain point is slow API response times.",
scope=scope,
)
print(response.final_response)
asyncio.run(main())
Next Steps¶
Your agent has domain-specific behavior and guardrails. Level 5 covers production deployment with consolidation, monitoring, and scaling.
Next: Production Deployment
See also:
- Feature Catalog § Domain Plugins —
every DomainTemplate field with an opt-in example.
- Guide 08 Fabrication Detection —
how identifier_keys feeds the fabrication lexicon.