Jev: a decision model for Home Assistant. Sensors, automation actions, and an Assist agent

I kept using an LLM to answer questions about my house that did not need an LLM. “Is the laundry done but still sitting in the machine” is a judgement, not an essay, and paying for a paragraph of generated text to get one number back felt like the wrong shape.

So I built an integration for TypeSafe Jev, which is a decision model rather than a chat model. You give it a typed question and it hands back a probability, one of your options, or a score. Nothing is generated, so there is nothing to parse and nothing to hallucinate.

GitHub | Docs | Examples

What you actually do with it

Three things.

Questions become sensors. Add one in the UI, pick what it should look at, and you get a sensor holding a number from 0 to 1. Set a threshold and you also get a binary sensor to trigger on.

Four actions for automations. jev.noul, jev.choice, jev.score and jev.ask, each returning the answer as a response variable. They take the normal target picker, so you select entities or an area and it builds the state for you instead of you writing a template.

A conversation agent for Assist. This one surprised me. It routes a spoken command in one request, maps it onto Home Assistant’s own intents, and hands anything it is not sure about to a fallback agent. About $0.00006 a command.

The bit I like most

Before a question saves, it shows you the state it will send and asks the question once, so you can see what it answers before the sensor exists.

Here is a simple one. The washing machine is drawing 1.4 W, the door is shut, and it finished 14 minutes ago:

And a more involved one. Four options, and it hands back the whole distribution rather than just a winner. This is my cable modem, where the signal to noise ratio has drifted under the healthy 33 dB and the upstream power is close to its ceiling:

Look at that second answer for a second. It says degraded at 0.59 with marginal right behind at 0.40, and a confidence of 0.46. It is telling me the data is genuinely ambiguous instead of picking one and sounding sure about it. That is the whole reason the actions hand back a confidence figure: my automation can insist on 0.8 before it wakes anybody up.

The trial answer costs between 350 and 876 input tokens depending on how much you point it at, so somewhere between $0.000015 and $0.000037. The footer on that screen tells you exactly what it spent. I have clicked it a few hundred times while writing questions and it has cost me less than a cent.

Where it gets interesting

The thing that changed how I write automations is that extra questions are nearly free. So instead of asking one thing and calling back for more, you ask everything you might need in one go and throw most of it away.

- action: jev.ask
  response_variable: jev
  target:
    entity_id: sensor.intercom_transcript
  data:
    questions:
      caller:
        type: choice
        instructions: Who is at the door?
        criteria:
          delivery: A courier dropping off a parcel
          visitor: Someone the household knows
          sales: An unsolicited caller
      answer_now:
        type: noul
        instructions: Does someone need to go to the door right now?
      urgency:
        type: score
        instructions: How quickly does this need a person?
        criteria: [Never, Whenever, Within the hour, Right now]

One request, three answers, about 300 ms. Then you branch on them:

- choose:
    - conditions: "{{ jev.answers.caller.confidence > 0.8 and jev.answers.answer_now.noul > 0.7 }}"
      sequence:
        - action: light.turn_on
          target: { entity_id: light.hall }
        - action: notify.mobile_app
          data:
            message: "{{ jev.answers.caller.choice }} at the door, {{ jev.answers.urgency.nearest_level }}"
    - conditions: "{{ jev.answers.caller.confidence > 0.8 }}"
      sequence:
        - action: logbook.log
          data: { name: Doorbell, message: "{{ jev.answers.caller.choice }}, no answer needed" }
  default:
    - action: notify.mobile_app
      data:
        message: "Someone at the door, not sure who. Read it as {{ jev.answers.caller.choice }} at {{ jev.answers.caller.confidence }}."

That default branch is the part I would not have bothered with before. When it is not sure, it says so out loud rather than silently doing nothing, and an automation that quietly does nothing is the hardest kind to debug.

The same shape works for anything where you are currently writing a pile of nested template conditions and still getting it wrong: is this notification worth interrupting someone for, is this a real alert or noise, is the house in a state where I should start the dishwasher.

Some numbers

I measured these against the live API from a normal connection here in the Netherlands, rather than taking the vendor page at its word.

  • 250 to 580 ms warm, 700 to 900 ms on the first call after it has been idle. TypeSafe publishes 70 to 500 ms, presumably measured next door to their own servers.
  • Adding questions is nearly free. Three questions took 712 ms, a hundred took 714. Adding a second call is what costs you.
  • One entity in the state is about 66 input tokens.

There is one finding that changed how I write questions. Jev makes a judgement, it does not do arithmetic. Hand it a power reading and expect it to work out which side of a threshold that falls on and you will be disappointed. Five runs each on an idle machine and a running one: the readings on their own separated the two cases by 0.21. Adding “this machine draws under 5 W when idle and over 300 W while a programme runs” to the background field took that to 0.60, and doing the comparison in Jinja instead took it to 0.69. Either fix roughly triples it, and they do not stack, so do one.

What it is not good at

  • Answers come with no reasoning, so there is nothing to audit afterwards.
  • Confidence has no published calibration. Treat 0.9 as higher than 0.6, not as right nine times out of ten.
  • It is not for safety decisions. A probability with no explanation should not be holding a lock or a heater.
  • The conversation agent does on, off, toggle, brightness and state questions. Everything else goes to the fallback agent.

Installing

Needs HA 2026.9 or newer and an API key from TypeSafe. The key is the only thing it asks for.

Not in the HACS default list yet, so add it as a custom repository: HACS, three dot menu, Custom repositories, paste https://github.com/AboveColin/HA-Jev, type Integration. The HACS submission is open, so those steps should go away eventually.

Docs are at jev.cdevries.dev and there are fifteen worked examples in the repo, four of them pairing Jev with an LLM for the cases where you genuinely want prose.

I am not affiliated with TypeSafe. Happy to answer questions, and I am curious what other people end up asking their houses.

7 Likes

Sounds like an AI replacing a bayesian sensor.

2 Likes

How much does it run you or is there a free tier?

This looks really cool. Could your integration be modified to work with the thecodacus’ llama.cpp fork the implements this concept via “Jev mode” and any model that you want to use that can run on llama.cpp. His video does the best job explaining what he did. It would be great if this could be run locally.

Thanks for putting this out there.

1 Like

This is very interesting. While reading about it I also came across convaiinnovations/laya · Hugging Face which is apparently similar but even faster and can be fully locally hosted rather than relying on external connections. Have you looked into Laya at all?

JEV and Jev - likes are just categorizers at thier core - given set X and input conditions - which element matches the known conditions.

That said it’s very. VERY useful for narrowing conditions and reducing to known possibilities which is incredibly helpful preprocessing llm context.

In short.

There’s about to be about a billion of these small JEV style models on HF yes they’re helpful. Evaluate them as one category. Don’t get hung up on one model yet.

2 Likes

There’s a free variant called Laya that might be more suitable for self hosting.

I actually built home assistant around system 1 type model, and you can choose jev or open-source Laya option. MIT licence, feel free to try and use: GitHub - ps1x/LayaAssitant: HomeAssistant assistant based on Laya system 1 model · GitHub

1 Like

Looks cool thanks for the heads up :+1:

Is it fine tuned Laya? Their card shows kind of bad results without fine tuning.

The shipped checkpoints work zero-shot, but fine-tuning on decisions from your own domain is where accuracy jumps. On the typed-decisions benchmark (2,000 decisions across four workflows), the fine-tuned laya-typed-decisions checkpoint scores 0.766 accuracy, against 0.362 for the base English checkpoint on the same decisions.

Without it would probably be just chance?

Thank you :slight_smile:
Am I reading this right that it would run reasonably well on cpu? I’m just interested in an easy way of finding out who is home, or somebody at all?

I’ve added the integration and exposed some entities to assist, but the agent keeps reporting that the action would be against a device that isn’t exposed. Any ideas?

Following up on my own question. I dug into it, and I think there are two separate things going on. The second looks like a bug.

1. One of my lights wasn’t actually exposed to Assist

My first trace ended in 1 entities although I’d exposed two office lights. Jev only counts an entity if async_should_expose(hass, "conversation", …) says so, which means it has to be exposed to Assist specifically. Exposing it to Alexa or Google doesn’t count. Any name that isn’t exposed ends up in hidden_names, and if you then say that name, Jev falls back. That part is working as designed. Worth knowing if anyone else sees the same message.

2. “Matt’s office light” is rejected even though the right light was picked

With both lights exposed (2 entities), this still fell back:

Turn on Matt’s office light

Jev: turn_on, named a device that is not exposed, confidence 0.83
action: turn_on 0.83 (turn_on 0.85, report 0.11, get_state 0.04)
target_type: entity 0.80 (entity 0.85, area 0.15, everything 0.00)
entity: light.matt_office_light 1.00 (light.matt_office_light 1.00, light.matt_office_garage_door_light 0.00, none_of_these 0.00)
area: Matt Office 1.00 (Matt Office 1.00, none_of_these 0.00)
jev-1.13.0, 1525 input tokens, 304 ms, 2 entities

The model got it right at 1.00. The fallback comes from _a_hidden_name_fits_better (called at interpret.py:832):

  • _words_said needs the chosen entity’s name to appear as one phrase (matt\s+office\s+light). “Matt**'s** office light” breaks it, so said = 0.
  • With said = 0, any hidden name that appears in the sentence wins. Because of (?<!\w)/(?!\w), the apostrophe counts as a word boundary, so a hidden name “Matt” matches inside “Matt’s”. Hidden names include every non-controllable entity in the house, so most likely my person entity, plus anything named “Office” or “Light”.

I ran the same regex logic against the sentence:

Name _words_said
Matt Office Light 0
Matt's Office Light 3
Office Light 2
Matt 1

So a confident, correct answer is overridden by my own name on a person entity.

Workaround: add the alias “Matt’s Office Light” to the light, so said = 3. (I can confirm this worked, although it is quite inconvenient to add aliases to entities)

Possible fixes:

  • Include the area/floor name when comparing matches
  • Remove possessive 's / ’s before matching in _words_said and _name_fit.
  • Skip the hidden-name check when said == 0, or when the entity answer is confident and the area answer matches that entity’s own area. As I read it, the check was meant for “desk lamp” landing on “Lamp”, and there the chosen name was at least partly said.

Happy to open a GitHub issue or a PR with a test for this sentence if that’s easier for you to track.