A Dynamic Capability Registry Keeps HarmonyOS Agents From Guessing What Tools Exist
After integrating an Agent page into a real application, there's an easily overlooked problem: how does the client side know which capabilities it can invoke? If these capabilities are buried only in server-side code, the client can't see parameter requirements or risk levels, so buttons have to be hardcoded on the page. Every time the server adds a capability, the client must be modified accordingly.
This time, the tools in the local Agent Gateway were organized into a capability registry. Each capability includes a name, description, input structure, risk level, whether confirmation is needed, and a return example. The HarmonyOS client no longer hardcodes buttons; instead, it dynamically reads this registry from /tools and decides which capabilities can be executed directly based on the registry's information.
Capabilities Must First Have a Readable Description
These capabilities ultimately land at a specific execution entry point: checking time, viewing machine status, checking URLs, writing to-dos, retrieving materials, inspecting devices. Where the entry point is written is secondary; the key is whether there is a description in front of the entry point that both the client and the model can understand.
The local Gateway runs first, and all capabilities are exposed uniformly from here. The model key is kept only in the Gateway environment variables. The HarmonyOS client only communicates with the Gateway and doesn't guess what interfaces the server has; instead, it first reads the capability list returned by /tools.
Each tool in the list has a fixed set of fields. Take http_check as an example; it carries a risk level and parameter requirements, not just a URL:
{
"name": "http_check",
"description": "Check whether an HTTP address is accessible, used for pre-release interface inspection.",
"riskLevel": "medium",
"needConfirm": true,
"inputSchema": {
"required": ["url"]
}
}
These fields directly determine how the client handles this capability. riskLevel determines how the risk is labeled on the page, needConfirm determines whether it can be executed directly, and inputSchema is handed to the Gateway for parameter validation. The model can choose which tool to use, but it cannot choose anything outside the list.
An ordinary API list tells developers "there's this URL," and that's enough. This registry takes on an extra layer: it records what the capability does, what parameters it needs, whether confirmation is required before invocation, and roughly what the return looks like, allowing both the client and the model to read it directly.
Low-Risk Capabilities Can Be Executed Directly
time_now and local_status are low-risk tools. They only read the local Gateway status, do not modify data, and do not touch external systems, so the client can allow them to be executed directly.
First, time_now and local_status are called in the terminal. The return includes the current time, timezone, system version, machine architecture, disk usage, and a requestId.
This requestId is useful. Even for low-risk tools, the identity of a single invocation must be preserved. When the same requestId appears in the terminal, client page, and Gateway logs, a single invocation can be traced back, eliminating the need to guess which invocation it was based solely on timestamps during troubleshooting.
Low risk does not mean validation can be skipped. The Gateway should first check whether the tool exists and whether the parameters conform to the schema, without skipping a single step. The client-side button is just an entry point; the real boundary enforcement is on the server side.
Calling an Unregistered Tool Gets Rejected Immediately
To see where the boundary lies, a completely unregistered tool was deliberately called:
{
"tool": "delete_everything",
"arguments": {}
}
The Gateway returned TOOL_NOT_FOUND. It did not perform any fuzzy matching, nor did it hand the matter over to the model for free interpretation.
This checkpoint is quite important. The biggest fear in an agent system is the appearance of "understanding everything"—the model interpreting a user's sentence as a non-existent capability and then expecting the server to improvise a workaround. With the registry, tool names must come from the whitelist; anything outside the whitelist is not executed.
Parameter Validation Must Be Enforced on the Server Side
note_search requires a query parameter. When it was deliberately omitted, the Gateway returned ARGUMENT_MISSING: query; when the parameter was provided, the tool ran normally.
This validation is placed on the server side and cannot be placed only within the ArkUI page. The client can provide friendlier prompts, but it cannot serve as the sole line of defense. As long as a request reaches the Gateway, parameters must be validated against the same schema; otherwise, switching entry points to bypass the page would create a loophole.
The inputSchema in the registry also lets the client know what parameters each tool requires. The current page initially displays the basic fields, which is already sufficient to demonstrate that the capability boundary originates from the server side, not from being hardcoded onto a specific button.
HarmonyOS Client Generates the Capability List from the Registry
The client-side page does not hardcode tools into an array. The core is a single loadTools() function that requests /tools, puts the returned tools into @State tools, and hands them to a List for rendering.
The page is built as a lightweight capability console: at the top are the total number of tools, the number directly executable, and the number requiring confirmation; in the middle, filtering by "All, Low Risk, Confirmation Required" is possible; each item lists the name, description, risk level, and confirmation strategy.
After loading, the HarmonyOS client displays 8 tools, 7 directly executable, and 1 requiring confirmation. These numbers are not hardcoded on the page; they are calculated from the registry returned by the Gateway.
This page is more like a capability console than a simple row of buttons. Users not only know that a capability exists but can also see its risk and execution conditions. For a medium-risk tool like http_check, the client only shows a locked state and does not allow direct clicking; low-risk and medium-risk are clearly separated on the same page.
Execution Results Return to the Same Panel
Clicking the execute button for time_now, the page assembles a requestId prefixed with tool-registry- and a timestamp, POSTs to /tools/call, and the "Recent Execution" area at the bottom directly returns a complete JSON segment. The page does not jump or pop up a dialog; the result stays right in this area.
The time_now result is short, suitable for first verifying the client-side execution loop; local_status brings back the system, machine architecture, disk usage, and check time, proving that this invocation actually read the machine state. The result stays in the same panel, allowing the user to match "which tool was clicked" with "what the tool returned."
The Client Adds an Even More Conservative Whitelist Than the Risk Level
The registry has 8 tools, 7 marked as low risk. But the page does not unlock all 7 for clicking. The canRun function that determines whether a tool can be executed, besides checking needConfirm, additionally enforces a tool name whitelist:
canRun(tool: ToolItem): boolean {
return !tool.needConfirm && (tool.name === 'time_now' || tool.name === 'local_status')
}
This means that even if the registry says a tool is low risk, this version of the client only unlocks time_now and local_status, which it has actually verified. The remaining low-risk tools are displayed as "read-only" on the page, and tools requiring confirmation are displayed as "locked." Both are "cannot click," but the client uses two different labels to distinguish them: one means "not yet unlocked in this version," and the other means "higher risk, requires a confirmation process." The risk level is a suggestion from the server; the client tightens the restriction further on top of this suggestion, preferring to unlock fewer rather than jump the gun.
The three states—executable, read-only, and locked—translate into code as an if (this.canRun(tool)) branch plus a ternary judgment. The page does not need to write separate logic for each tool; it relies entirely on the registry fields plus this layer of client-side policy. Filtering follows the same logic: visibleTools() filters by filterMode, and lowRiskCount() and confirmCount() are calculated directly on @State tools. The three statistics at the top and the list in the middle all follow the data returned by the registry; none are hardcoded constants.
The Capability Boundary Is Thus Defined
At this point, the capability boundary on the local Gateway side is clear: the model can only pick tools from the registry and cannot pick anything outside the list; parameters not conforming to the schema will be blocked at the Gateway; medium-risk tools are already marked in the registry as requiring confirmation. The client side no longer relies on hardcoded buttons. Adding a capability or changing a capability's description or risk level means the page simply re-fetches /tools and updates accordingly, without touching the ArkTS code.
This registry is self-built locally and is not a formal capability release process. But it brings together the few things most easily missed when adding a capability: how the capability is described, when it should be triggered, what parameters it needs, how high the risk is, and what it returns. Missing any one of these means the client either has to rely on hardcoded buttons for rigid integration or falls back to "the model casually says an interface name, and the server improvises a fix." By centralizing this information into a registry and having the client read it, adding and modifying capabilities changes from "modifying code in two places" to "modifying a configuration in one place."