LLM API
How Home Assistant exposes tools to large language models, how an integration contributes tools or a whole API, and how APIs are served over MCP.
Large language models (LLMs) can do more than chat when they get tools. An LLM API in Home Assistant is a set of tools, plus a prompt that explains how to use them. A conversation agent that wraps a language model hands the tools of the API the user picked to the model, and runs the tools the model calls.
Home Assistant comes with two APIs built in, and integrations can add tools to them, or register an API of their own.
There are three sides to this:
- Use an API, in a conversation agent. That is covered by the conversation entity, with
chat_log.async_provide_llm_data. - Contribute tools to the APIs that exist. See contribute tools.
- Build your own API. See build your own API.
The llm integration registers two APIs. Neither has tools of its own: all their tools come from the llm.py platforms of integrations, described under contribute tools.
- Assist, with the ID
assistand shown to users as "Device control", controls and reads the entities the user exposed to Assist. Theintentintegration offers the common intents to it as tools, likeHassTurnOnand the timer intents, and entity integrations offer their own, like setting the speed of a fan. It cannot do anything administrative. - Home Assistant, with the ID
homeassistant, manages Home Assistant itself, like registries, system logs, automations, and config entries. It is not limited to exposed entities, and only administrators can use it.
Registering an intent does not make it a tool. To offer your intent to models, return an llm.IntentTool for its handler from your llm.py platform. The tool uses the description of your intent handler, so write it for the model: that is what it reads to decide when to call it.
An integration that talks to a language model lets the user pick the APIs in its options flow. Store the chosen API IDs under CONF_LLM_HASS_API from homeassistant.const, as a list. If the user picks none, leave the key out.
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
)
apis = [
SelectOptionDict(label=api.name, value=api.id)
for api in llm.async_get_apis(hass)
]
schema = probatio.Schema(
{
probatio.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
): SelectSelector(SelectSelectorConfig(options=apis, multiple=True)),
}
)When the user picks several APIs, Home Assistant merges them into one for the model.
An integration can add tools to the APIs without owning one, with an llm.py platform. It has an async_get_tools callback, which the llm integration calls for each request with the context of that request and the ID of the API being put together. Return the tools for that API, with an optional prompt that goes with them, or None to contribute nothing.
Start the name of every tool you contribute this way with your domain and two underscores, like my_integration__coffee_count. Home Assistant reports tools without that prefix in the log, and they stop working in Home Assistant 2027.3.
from homeassistant.components import llm
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.llm import LLMContext
@callback
def async_get_tools(
hass: HomeAssistant, llm_context: LLMContext, api_id: str
) -> llm.LLMTools | None:
"""Return the tools of my integration."""
if api_id != "assist":
return None
return llm.LLMTools(
tools=[CoffeeCountTool()],
prompt="Use my_integration__coffee_count for coffee questions.",
)Because it runs per request, the tools can depend on the context: the device the request came from, the assistant, or the API. The platform is imported only when it is needed, and your integration does not need to depend on llm.
A tool is a subclass of llm.Tool from homeassistant.helpers.llm:
import probatio
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
from homeassistant.util import dt as dt_util
from .const import DOMAIN
class TimeTool(llm.Tool):
"""Tell the time."""
name = "GetTime"
title = "Get the time"
description = "Returns the current time."
integration = DOMAIN
annotations = llm.ToolAnnotations(
read_only=True,
destructive=False,
idempotent=True,
open_world=False,
)
parameters = probatio.Schema({probatio.Optional("timezone"): str})
async def async_call(
self,
hass: HomeAssistant,
tool_input: llm.ToolInput,
llm_context: llm.LLMContext,
) -> llm.ToolResult:
"""Return the current time."""
tzinfo = dt_util.DEFAULT_TIME_ZONE
if timezone := tool_input.tool_args.get("timezone"):
tzinfo = dt_util.get_time_zone(timezone)
return llm.ToolResult(
data={"time": dt_util.now(tzinfo).isoformat()}
)| Attribute | Description |
|---|---|
name |
Required. The name the model calls the tool by. |
integration |
Required. The domain of the integration that provides the tool. |
description |
What the tool does, written for the model. Optional, but the model is lost without it. |
title |
A name to show people. Optional. |
parameters |
A probatio schema of the arguments. Defaults to no arguments. |
annotations |
A ToolAnnotations that says how the tool behaves. |
A tool without integration is reported: as an error for core integrations, and as a warning in the log for custom integrations, until it stops working in Home Assistant 2027.10.
async_call gets hass, a ToolInput, and the LLMContext of the request:
ToolInputhas thetool_name, thetool_args(validated againstparameters), anidfor the call, andexternal. An external call is one that something else already handles, so the chat log records it but does not run it through the LLM API. A conversation agent sets it for tools its model provider runs, like a web search, and the built-in conversation agent sets it for the intents it handles itself. Calls that the chat log runs for your tool haveexternalset toFalse.LLMContexthas theplatform(the domain of the agent), thecontextwith the user, thelanguage, theassistantwhose exposed entities apply, and thedevice_idthe request came from.
Return a ToolResult. Its data must be a dictionary that can be serialized to JSON. Returning a plain dictionary is deprecated and stops working in Home Assistant 2027.11.
When the tool cannot do its job, raise a HomeAssistantError. Home Assistant catches it and tells the model the call failed. To word the failure for the model yourself, return a result with error set:
return llm.ToolResult(data={"error": "Calendar not found"}, error=True)Annotations tell the model how careful to be. They match the tool annotations of the Model Context Protocol, and the MCP Server integration passes them on.
| Annotation | Meaning | Default |
|---|---|---|
read_only |
The tool changes nothing. | False |
destructive |
The tool can change or remove something that exists. | True |
idempotent |
Calling it again with the same arguments changes nothing more. | False |
open_world |
The tool reaches outside Home Assistant. | True |
The defaults assume the worst, so declare what is true for your tool.
An API is a subclass of llm.API. It returns an APIInstance: the tools, and a prompt that tells the model how to use them.
from homeassistant.helpers import llm
class MyAPI(llm.API):
"""An LLM API of my integration."""
async def async_get_api_instance(
self, llm_context: llm.LLMContext
) -> llm.APIInstance:
"""Return the tools for this request."""
return llm.APIInstance(
api=self,
api_prompt="Use the tools to look up data in my service.",
llm_context=llm_context,
tools=[TimeTool()],
)
async def async_setup_entry(
hass: HomeAssistant, entry: MyConfigEntry
) -> bool:
"""Set up my integration."""
entry.async_on_unload(
llm.async_register_api(
hass,
MyAPI(
hass=hass,
id=f"my_integration-{entry.entry_id}",
name=entry.title,
),
)
)
return Truellm.API takes keyword arguments only: hass, a unique id, and the name users see. Set requires_admin=True for an API that only administrators may use. async_register_api returns a function that unregisters the API: when the API belongs to a config entry, call it on unload, like above.
APIInstance can also take a custom_serializer: a function that turns the schemas of your tools, like selectors, into the JSON schema a model expects.
Every registered API is available over the Model Context Protocol once the user sets up the MCP Server integration. You do not need to do anything for it.
Each API has its own Streamable HTTP endpoint, at /api/mcp/<API ID>. The Assist API is at /api/mcp/assist. All APIs except Assist need an access token of an administrator there. The integration also serves the API the user configured for it at /api/mcp, for clients that do not pick one.
To find out which APIs exist, an administrator can list them over the WebSocket API, in the order they were registered:
{
"id": 1,
"type": "llm/api/list"
}{
"id": 1,
"type": "result",
"success": true,
"result": {
"apis": [
{"id": "assist", "name": "Device control", "requires_admin": false}
]
}
}