Build

Add a new tool

A new capability in FRIDAY is two things, always together: the capability itself, and the intent pattern that routes to it. Skip the pattern and the small chat model will happily fabricate success for a tool it never called.

The rule
Every capability registered via app.register_capability(...) — new or fixed — must also have an intent pattern in core/intent_recognizer.py, unless it is intentionally LLM-routed only (rare). context_terms, aliases, and description feed the LLM RouteScorer, not the deterministic recogniser.

1. Register the capability

Inside your module's setup(app), register a tool spec, a handler, and capability metadata. The handler receives the raw text and a parsed args dict.

python · modules/your_module/plugin.py
def setup(app):
    app.register_capability(
        spec={
            "name": "set_brightness",
            "description": "Set the display brightness to a percentage.",
            "parameters": {"level": {"type": "integer"}},
            "aliases": ["brightness", "screen brightness"],
            "context_terms": ["dim", "bright", "display"],
        },
        handler=handle_set_brightness,
        connectivity="local",       # local | online
        side_effect_level="write",  # read | write | critical
    )

def handle_set_brightness(text, args):
    level = int(args.get("level", 50))
    # ...do the work...
    return f"Brightness set to {level}."

2. Add a deterministic intent pattern

Pick (or add) a _parse_<domain> method in core/intent_recognizer.py, add your regex(es), and return the canonical action dict. Always gate on tool presence so the parser is harmless when the capability isn't loaded.

python · core/intent_recognizer.py
def _parse_brightness(self, clause: str):
    # Gate: do nothing if the capability isn't registered
    if "set_brightness" not in getattr(self.router, "_tools_by_name", {}):
        return None

    m = re.search(
        r"\b(?:set|change|make|put|turn)\b.*\bbrightness\b.*?(\d{1,3}|max|min|fifty)",
        clause, re.I,
    )
    if not m:
        return None

    raw = m.group(1).lower()
    level = {"max": 100, "min": 0, "fifty": 50}.get(raw, None)
    level = level if level is not None else max(0, min(100, int(raw)))

    return {
        "tool": "set_brightness",
        "args": {"level": level},
        "text": clause,
        "domain": "system",
    }

Then register the parser in the _parse_clause chain, minding order:

ordering matters
# Narrow / explicit parsers go BEFORE broad catch-alls.
# e.g. _parse_screen_lock before _parse_help so "lock screen"
# never matches a help query.
for parser in (
    self._parse_pending_selection,   # guards always first
    ...
    self._parse_brightness,          # your new parser
    ...
    self._parse_greeting,            # lowest priority
):

3. Make the pattern robust

Cover at least these axes so real speech actually matches:

  • Verb variants — set / change / make / put / turn.
  • Object variants — “lock screen” / “lock the screen” / “lock yourself”.
  • Word order — “brightness 80” and “set 80 brightness”.
  • Spoken cardinals — “fifty” → 50, “max” → 100, “minimum” → 0.
  • Optional argument shapes — “unlock screen” and “unlock with pin 1234” route to the same tool.
  • Filler tolerance — “Friday rescan my apps” and “rescan apps please”.
Negative cases matter too
Never match on a single common word (battery, volume, screenshot) without a verb anchor — those words appear in unrelated sentences (“the battery in my car died”) and cause false-positive routing. Don't poach phrasings that belong to another parser.

4. Tests are mandatory

Add tests/test_<domain>_intent.py following the _make_recognizer(tools=[...]) pattern. Parametrize the positive phrasings and include at least one negative phrasing that must not match.

python · tests/test_brightness_intent.py
import pytest
from tests.helpers import _make_recognizer

@pytest.mark.parametrize("phrase,level", [
    ("set brightness to 60", 60),
    ("make the screen brightness fifty", 50),
    ("turn brightness to max", 100),
    ("brightness 80", 80),
])
def test_brightness_matches(phrase, level):
    rec = _make_recognizer(tools=["set_brightness"])
    action = rec.plan(phrase).steps[0]
    assert action.tool == "set_brightness"
    assert action.args["level"] == level

def test_brightness_negative():
    rec = _make_recognizer(tools=["set_brightness"])
    assert rec.plan("the future is bright for us").steps == []

5. Update the testing guide

Add or update the matching T-N.M entry in docs/testing_guide.md. Its You say field lists the natural phrasings a user can speak — which doubles as the live spec of what your parser must accept.

That's the whole loop
Capability + intent pattern + robust phrasings + tests + testing-guide entry. Do all five and your tool is a first-class citizen of the deterministic router — reliable on a local model, with no LLM in the hot path. Revisit How routing works for why each piece matters.