Skip to content

Skill System

The skill system provides modular, composable capabilities that the AI agent can use to generate responses. Skills are organized by category and can be enabled/disabled per client.

Architecture

Skill Definition

Each skill is defined in a SKILL.md file with YAML frontmatter:

---
name: skill_name
default_enabled: true
description: Brief description for LLM selection
category: response_ending
required: false
emoji: "🎯"
always_on: false
---

# Instructions

Detailed instructions for the LLM...

Skill Examples

Skills use two types of examples to guide LLM behavior:

1. Triggers (Frontmatter)

trigger_examples in the YAML frontmatter help the skill selector LLM decide WHEN to select a skill. They appear in the Triggers column of the selector prompt. Only the first 5 are shown. These can be example visitor messages or semantic trigger descriptions.

trigger_examples:
  - "We're a fintech company"    # Industry signal
  - "What ROI can I expect?"     # Results signal
  - "Do you have case studies?"  # Proof signal

Best practices for trigger_examples:

  • Use semantic variety (different ways to express the same intent)
  • Avoid language-specific phrases; prefer patterns the LLM can generalize
  • Put the most important trigger type first (first 5 are shown to LLM)
  • Match the categories described in the description field

2. Instruction Examples (Body)

The # examples section in the skill body shows the answer LLM HOW to use the skill correctly once selected. Use GOOD/BAD patterns:

# examples

**GOOD - Natural transition to demo:**
**Visitor:** "We're looking to automate our onboarding"
**Agent:** "Automating onboarding can reduce manual work...

👇 Feel free to connect with a Sales expert using the button below."

**BAD - Missing emoji:**
**Agent:** "Feel free to connect with our Sales team."
**Why bad:** Missing 👇 emoji at the beginning of the last sentence.

Best practices for instruction examples:

  • Always explain Why bad for BAD examples
  • Show realistic visitor/agent exchanges
  • Cover edge cases and common mistakes
  • Keep examples generic (not client-specific) in global skills

Skill Categories

Category Purpose Examples
response_ending How to end responses demo_offer, content_gating, content_recommendation, context_gathering, clean_ending
response_handling Content generation pricing, competitors, support
content_enhancement Client-specific content extras Client-specific: case_studies, positioning, product_knowledge
system Auto-injected context knowledge_retrieval, visitor_profile, conversation_history

Category Definition (CATEGORY.md)

Each category folder contains a CATEGORY.md file that configures how the category behaves in both the skill selector and the answer writer.

See backend/apps/shared_data/prompts/website-agent/skills/response_ending/CATEGORY.md for a full example.

CATEGORY.md Fields

Fields are consumed by two different LLMs:

Field Consumer Purpose
display_name Answer Writer Heading text in assembled prompt
answer_instructions Answer Writer Instructions shown before skills in main prompt
uncertainty_guidance Answer Writer Shown when multiple skills from category are selected
selector_description Skill Selector Brief description to help LLM understand category
selector_guidance Skill Selector Detailed rules for when to select skills
order Both Sort order for categories (lower = first)
hidden_from_llm Both If true, skills in category are auto-injected by code

How Categories Affect Skill Selection

The selector_guidance field is critical for LLM decision-making. Use it to:

  1. Define defaults: "clean_ending is the DEFAULT for most responses"
  2. Create decision frameworks: List conditions for each skill in priority order
  3. Set exclusivity rules: "content_gating > context_gathering when visitor states industry"
  4. Handle uncertainty: "When uncertain, select BOTH and let the answer LLM decide"

Always-On Skills

Skills with always_on: true in their YAML frontmatter are always injected into the answer prompt, regardless of whether the LLM skill selector picked them. This is useful when the skill selector cannot determine if a skill is needed (e.g., because the relevant context isn't available yet at selection time), but the answer LLM can decide at generation time.

When to use always_on:

  • The skill depends on RAG context that isn't available during skill selection
  • The answer LLM should conditionally apply the skill based on its own judgment
  • The skill adds a fallback behavior (e.g., offering a CTA when the bot can't answer)

Example: The book_a_call skill for augment.org uses always_on: true because the skill selector runs before RAG retrieval and cannot know if the knowledge base lacks information. The answer LLM sees the RAG context and decides whether to include the advisor booking link.

Per-Locale Skill Loading: conditions

The conditions frontmatter field lets a skill declare hard-gate predicates that must match before the skill is shown to the LLM selector at all. Skills whose conditions don't match are filtered out before the selector LLM sees the candidate list, so the wrong-region or wrong-language skill text never enters the prompt — eliminating the "lost in the middle" failure mode where RAG context drowns out branching prose inside a single skill.

Supported condition keys (evaluated in _condition_matches in backend/packages/ixskills/ixskills/skill_registry.py):

Key Value Match semantics
starts_with_any list of URL prefixes page URL starts with one of them at a / boundary (trailing-slash tolerant). Regex-free; preferred for page targeting
does_not_start_with_any list of URL prefixes page URL starts with NONE of them. Catch-all counterpart of starts_with_any
page_url_matches regex re.search(value, condition_values["page_url"]) must hit. Legacy — prefer starts_with_any
page_url_not_matches regex re.search(value, condition_values["page_url"]) must NOT hit. Legacy — prefer does_not_start_with_any
conversation_language language tag must equal condition_values["conversation_language"]
*_availability (e.g. content_gating_availability: allowed) typically allowed must equal resolved availability

Prefix keys take a list: conditions: [{ starts_with_any: ["/en-gb"] }]. A prefix of /en-gb matches /en-gb and /en-gb/compare but not /en-gbexit (anchored at a path-segment boundary). Path prefixes (start with /) are matched against the URL path only; absolute prefixes (start with http) against the whole URL.

Multiple conditions on one skill use AND semantics — every condition must match. A skill with no conditions: block always passes the filter (catch-all).

Always-on interaction: the same filter applies to always-on injection (get_always_on_skills in skill_registry.py), so per-locale always-on skills like regulatory_framing-uk only inject for visitors whose page_url matches.

Example layout (ibanfirst.com pricing split):

clients/ibanfirst.com/response_handling/
├── pricing-uk/SKILL.md       # conditions: [{ page_url_matches: "uk\\.ibanfirst\\.com|/en-gb/" }]
├── pricing-fr/SKILL.md       # conditions: [{ page_url_matches: "fr\\.ibanfirst\\.com|/fr/" }]
└── pricing/SKILL.md          # conditions: [{ page_url_not_matches: "uk\\.ibanfirst\\.com|/en-gb/|fr\\.ibanfirst\\.com|/fr/" }]

The EU/default catch-all keeps the canonical name (pricing) so it overrides the global pricing skill via base-name lookup. UK and FR variants use suffixed names with explicit page_url_matches. Result: each visitor sees exactly one matching skill per category, with no wrong-region content in the prompt.

Failure modes:

  • Bad regex: re.error at request time → condition treated as not matched → skill skipped (fail-closed). Logs at warn.
  • Missing field (e.g. visitor metadata lacks page_url): same as bad regex — skill skipped, implicit catch-all loads instead.
  • All region skills mutually exclusive (no catch-all): a visitor with an unmatched URL gets no skill in that category — design the catch-all explicitly via page_url_not_matches or by leaving conditions off the fallback skill.

Skill description guidance: don't repeat the region in the skill's description. The hard-gate filter already routes by URL — the LLM doesn't need "UK visitors only" framing because it never sees the wrong-region variants. Mirror the description across regional variants so the LLM selector picks consistently regardless of which one is shown.

See backend/packages/ixskills/tests/test_skill_registry_region_filter.py for the full matrix of region-filter tests.

Config-Gated Variants: competitors (Tier 1 / Tier 2 / Tier 3)

The competitors skill resolves to one of three sibling files at lookup time, driven by per-client config fields rather than per-client SKILL.md duplication. Used for clients with ingested competitive battlecards (e.g. hyperline.co).

Files:

Path Skill name: hidden_from_llm: Role
response_handling/competitors/SKILL.md competitors false Tier 1. Generic defer. Unchanged from pre-IX-3166.
response_handling/competitors-aware/SKILL.md competitors_aware true Tier 2. Canonical naming + roster-acknowledged defer.
response_handling/competitors-battlecard/SKILL.md competitors_battlecard true Tier 3. Rep-voice fighting / battlecard-grounded.

The Tier 2/3 files use unique names + hidden_from_llm: true so they do not appear in the skill selector menu. The LLM selects competitors; skill_registry.get_skill("competitors", client_id, site_flags) returns the right variant.

Config slug (global scope): competitors — schemas/configs/global/competitors.schema.json

Field Default Effect
names: list[str] [] Canonical competitor roster. Non-empty triggers Tier 2. Used by {db_competitors_names} placeholder for canonical naming.
battlecard_mode: bool false Client-visible opt-in. Non-empty names + battlecard_mode=true → Tier 3. Requires battlecards ingested in the KB; safety fallback in the Tier 3 body defers if retrieval surfaces no chunks.

Resolution order in skill_registry.get_skill:

1. clients/<client_id>/competitors          (per-client override — wins, current 15 clients unaffected)
2. if site_flags.battlecard_mode AND site_flags.competitors_names non-empty:
       competitors_battlecard               (Tier 3)
3. if site_flags.competitors_names non-empty:
       competitors_aware                    (Tier 2)
4. competitors                              (Tier 1 fallback — current default)

Variant logic is scoped to name == "competitors". Other skill names take the unchanged code path.

Tier matrix (for clients without an override):

names battlecard_mode Tier Behavior
[] false 1 Generic defer (identical to today)
[] true 1 Mode no-op without names
non-empty false 2 Canonical naming + roster-acknowledged defer
non-empty true 3 Rep-voice fighting

Wiring:

  • backend/packages/ixchat/ixchat/nodes/skill_applier.py:_build_skill_instructions reads resolver.competitors.{names,battlecard_mode} and passes as site_flags to aggregate_skill_instructions.
  • aggregate_skill_instructions(..., site_flags) propagates to registry.get_skill(name, client_id, site_flags) for each skill in the answer prompt.
  • db_competitors_names placeholder populated in build_prompt_parameters (answer.py) from resolver.competitors.names — used inside Tier 2/3 skill bodies for canonical-name guidance.

Tests: backend/packages/ixskills/tests/test_skill_registry_variant.py covers all four branches + per-client override precedence + variant-scoped-to-competitors safety property.

Onboarding a new battlecard client:

  1. Ingest battlecards via rose-scrape-internal-docs.
  2. Verify chunks retrieve via rose-chat.
  3. Set competitors.names via the backoffice (tags input) or rose-config.
  4. Verify Tier 2 — canonical naming, defer answers.
  5. Flip competitors.battlecard_mode = true.
  6. Verify Tier 3 — rep-voice grounded answers.

Declarative Skill Composition: dependencies, augments, and blocks

Three frontmatter fields let a skill declaratively shape the final skill set when it is selected:

Field Effect
dependencies: [skill_a, skill_b] When this skill is selected, add these other skills to the selection.
augments: [skill_a] Link this skill with another skill. If either side is selected, the other side is included.
blocks: [skill_x, skill_y] When this skill is selected, remove these other skills from the selection.

Use augments when a client-specific skill should add posture or facts to a generic policy without replacing that generic policy. Same-name client skills still override global skills; augments is the safer pattern when the shared behavior must remain active.

augments is bidirectional:

LLM selects competitors                 → client_competitor_posture is added
LLM selects client_competitor_posture   → competitors is added

There are two operating modes:

Hidden augmenter — one selector-facing direction

Use this when the generic parent should be the only skill shown to the LLM, and the client skill is purely companion instructions:

---
name: client_competitor_posture
category: response_handling
hidden_from_llm: true
augments: [competitors]
---

The LLM cannot select client_competitor_posture directly. Runtime behavior is effectively: competitors selected → client_competitor_posture added.

Visible augmenter — both selector-facing directions

Use this when the client skill has useful selector metadata that may catch client-specific phrasings the generic parent misses:

---
name: client_competitor_posture
category: response_handling
hidden_from_llm: false
augments: [competitors]
---

Now both paths are possible: if the LLM selects competitors, the client posture is added; if the LLM selects client_competitor_posture, the generic competitors policy is added. This is the right mode when the client skill's description / trigger_examples are intended to help selection, not just answer generation.

blocks is the inverse of dependencies. Use it for declarative exclusivity — e.g. an inline-link skill that must suppress the standard CTA button because the link captures the conversion instead:

---
name: b2b_request
category: content_enhancement
blocks: [demo_offer]   # Inline Typeform link replaces the Enroll Now CTA
---

blocks is enforced by _rule_apply_skill_blocks in skill_applier_rules.py, which runs as a final pass after both ENDING_RULES and SYSTEM_RULES. This means a blocks directive overrides the priority-based exclusivity in the ending rules — even if demo_offer would normally win its priority race, a selected skill with blocks: [demo_offer] removes it.

When to prefer blocks over a custom rule in skill_applier_rules.py:

  • The exclusion is intent-driven, not state-driven (no need to inspect context or state)
  • The exclusion is specific to one or two skills, not a pattern across many
  • The exclusion lives naturally with the skill that triggers it (easier to reason about than a far-away rule)

If the exclusion depends on runtime state (e.g. visitor profile, turn number, feature flags), write a custom rule instead.

Skill Selection Pipeline

The skill_selector node uses a two-phase pipeline:

LLM Selection → Post-Processing Rules → Final Skills

Phase 1: LLM Selection

The LLM selects skills based on conversation context and skill descriptions.

Phase 2: Post-Processing Rules

Mechanical rules enforce priority and exclusivity:

  1. Ending Rules (priority-based, first match wins):
  2. demo_offer - Priority 1, removes all other ending skills
  3. content_gating - Priority 2, removes context_gathering/clean_ending
  4. context_gathering - Priority 3, can add clean_ending

  5. System Rules (all apply additively):

  6. visitor_profile - Injected if valid company data exists
  7. conversation_history - Injected if turn > 0
  8. knowledge_retrieval - Always injected (RAG context)
  9. Always-on skills - Any skill with always_on: true for the current client

  10. Final Rules (run after ending + system rules; never short-circuit):

  11. _rule_apply_skill_blocks - Removes any skill listed in a selected skill's blocks frontmatter

Skill Toggles

Skills can be enabled/disabled per client via the skill_toggles column in the agent_config table.

Default Behavior

Skills are ENABLED by default unless they have default_enabled: false in their SKILL.md metadata.

Configuration

-- Enable content_gating for a client
UPDATE agent_config
SET skill_toggles = '{"content_gating": {"enabled": true}}'
WHERE site_domain = 'example.com';

Lookup Cascade

1. agent_config.skill_toggles (explicit toggle)
2. SKILL_DEFAULT_ENABLED (from SKILL.md metadata)
3. True (default - skills enabled unless explicitly disabled)

Content Gating Skill

The content_gating skill enables email capture by offering valuable content in exchange.

How It Works

2-Step Flow:

  1. Turn N (Offer): Agent answers question + offers content with 💌 emoji
  2. Turn N+1 (Capture): Agent asks for email if visitor accepts

Availability States

State Condition Behavior
allowed Enabled, no restrictions Skill can be selected
discouraged Within cooldown period LLM discouraged from selecting
forbidden Disabled OR email captured Skill removed from selection

Cooldown Logic

After offering content (Turn N): - Turn N+1: Allowed (must handle visitor response) - Turn N+2 to N+3: Discouraged (cooldown active) - Turn N+4+: Allowed (cooldown passed)

Qualification Criteria

Optional criteria that trigger content gating offers:

UPDATE agent_config
SET qualification_criteria = '["number of support agents", "current tool"]'
WHERE site_domain = 'example.com';

When visitors mention these criteria, the LLM is more likely to offer relevant content.

Adding New Skills

1. Create SKILL.md

mkdir -p backend/apps/shared_data/prompts/website-agent/skills/{category}/{skill_name}/
---
name: my_skill
default_enabled: true
description: When to use this skill
category: response_ending
emoji: "🎯"
always_on: false              # Set to true to bypass LLM selector and always inject
dependencies: []              # Optional: skill names auto-included with this one
augments: []                  # Optional: bidirectional pairing with another skill
blocks: []                    # Optional: skill names removed when this one is selected
---

# Instructions

Your skill instructions here...

2. Add Post-Processing Rules (if needed)

For ending skills with priority/exclusivity rules, update skill_selector.py:

ENDING_RULES = [
    _rule_demo_offer,
    _rule_content_gating,
    _rule_my_skill,  # Add new rule
    _rule_context_gathering,
]

3. Add Default Toggle (if disabled by default)

# In agent_config.py
SKILL_DEFAULT_ENABLED: dict[str, bool] = {
    "content_gating": False,
    "my_skill": False,  # Requires explicit enablement
}

4. Test

poetry run pytest packages/ixchat/tests/test_skill_selector.py -v