Why meta-tools exist
A QiForge oracle can have dozens of plugins installed. Listing every one in the prompt would explode the system prompt and the tool list the model has to reason over. Instead, on-demand plugins are not advertised at boot — the agent discovers them mid-conversation via two built-in meta-tools and loads what it needs for the current thread.
Under the hood every tool is bound to the agent at compile time; a CapabilityGateMiddleware hides on-demand tools from the model until their plugin is loaded (see How on-demand gating works). So "loading" lifts a per-turn filter — it does not bind new tools.
The runtime registers these meta-tools itself. They are not authored by any plugin and cannot be overridden.
The two tools
| Tool | Purpose |
|---|---|
list_capabilities | Returns every visible plugin with its summary, visibility, loaded status, category, and tags. The agent uses this to scan what's available. |
load_capability | Takes names: string[] — an array, so the agent can load several capabilities in one call. Marks each plugin as loaded for the current thread, returns each one's full manifest plus tool descriptions, and appends a ToolMessage so the agent sees the manifests in conversation history on the same turn. |
load_capability accepts a batch and is processed per name: it throws when a named plugin doesn't exist, throws when one is silent (silent plugins are internal, not agent-loadable), and treats a name that is always or already loaded as a no-op (alreadyAvailable: true). Batching is preferred — the prompt instructs the agent to pass every capability it needs in a single load_capability({ names: [...] }) call rather than making multiple calls in the same turn.
On the Cloudflare Workers runtime, a plugin can also declare the User-controlled Authorization Network (UCAN) capabilities it requires. If the user's delegation doesn't grant them, load_capability returns that plugin with a refused field instead of loading it, and the rest of the batch still loads. list_capabilities marks the plugin unavailable.
From @ixo/oracle-runtime-workers 0.39.0, load_capability on the Workers runtime returns less and refuses instead of throwing:
- Each loaded plugin comes back with its tool names, not their descriptions. The full definitions reach the model in its tool list on the next step, so they are not sent twice.
- It also returns the plugin's usage (
summary,whenToUse,whenNotToUse,examples) and its operating guide, unless the plugin isalwaysvisible, whose guide is already in the system prompt. - An unknown or
silentname comes back as arefusedentry that names what can be loaded, and the rest of the batch still loads. - A plugin with nothing left to load comes back as
alreadyAvailable: truewith its tool names and a note to call them directly.
The discovery loop
In practice the agent often skips list_capabilities and goes straight to load_capability when the user's intent maps obviously to a manifest the agent has seen recently. It falls back to listing when the intent is ambiguous.
On the Workers runtime (0.39.0 and later) the system prompt names every capability that can be loaded in a compact on-demand catalogue, so reaching one takes a single load_capability call and no list_capabilities. list_capabilities is still bound for the full listing.
Calling a hidden tool
The model sometimes calls a tool it was never shown: a name from an earlier thread, a guess, or one planted by injected text. The capability gate never runs such a call, because the model has not seen the tool's schema. On the Workers runtime (0.39.0 and later) it answers with an error and loads the tool's plugin in the same step, as load_capability would, with the plugin's usage and operating guide in that error. The tool is in the model's list on its next step, so the mistake costs one step instead of two. Loading this way grants nothing load_capability would not: a plugin the user's delegation does not cover stays refused.
The loadedPlugins state field
The single new field added to the graph state. Per-thread, monotonically growing — load_capability only appends, never removes. The checkpointer persists it for free.
Loading a plugin in thread A does not affect thread B. Every new conversation rediscovers fresh.
On the Workers runtime (0.39.0 and later) loadedPlugins can also shrink: a cold turn unloads plugins the thread stopped using. See Loading and unloading on the Workers runtime.
What the agent sees in the system prompt
For always plugins, the runtime renders a Tier-1 prompt block. Each entry is more than a one-liner — the plugin name and summary, then sub-bullets pulled straight from the manifest (whenToUse, whenNotToUse, and the first example):
## Available Capabilities
- **memory** — Durable memory across conversations.
- When to use: user refers to something from a past chat; you learn a durable fact
- Avoid for: one-off lookups that don't need to persist
- Example: "remember I prefer metric units" → save_memory({"text":"prefers metric"})
- **skills** — Discover and run IXO skill capsules.
- When to use: a packaged skill could generate a file, report, or run a workflow
- Example: "make me an invoice" → search_skills({"query":"invoice"})
For more capabilities, call `list_capabilities()` to see what else is available,
then `load_capability({ names: ["cap1", "cap2"] })` to make their tools available in one call.Because whenToUse, whenNotToUse, and the first example all render into Tier-1, manifest quality directly drives prompt size. on-demand plugins are not listed here — the agent has to discover them. silent plugins are never listed and can't be loaded through the meta-tools.
On the Workers runtime (0.39.0 and later) the block ends with a one-line-per-plugin catalogue instead of the list_capabilities hint. It lists every plugin with something left to load this turn: an on-demand plugin, or an always plugin that holds tools back with onDemandTools. Each line is the manifest's brief, or its summary clipped to about 60 characters:
## On-demand capabilities
Not in your tool list until loaded. Call `load_capability({ names: [...] })` once with all you need, right from this list (no `list_capabilities` needed); the tools join your list on the next step. Anything already in your tool list needs no loading.
- composio: Gmail, GitHub, Slack, Calendar and hundreds of apps
- memory: add or delete oracle documents, knowledge; delete memories
- sandbox: run code; run or publish skills; generate charts and reports
- vfs: the user's library: save, read, search, organise files at any pathA plugin the user's delegation does not cover is listed with (not authorized by this user), so a load can say what is missing. The catalogue does not change when the thread loads something, so it never costs a prompt-cache miss. The Tier-1 example of an always plugin skips any example whose tool is still behind load_capability.
Token cost
| Item | Cost per turn |
|---|---|
| Tier-1 prompt block | ~80–150 tokens per always plugin (name + summary + whenToUse/avoid/example sub-bullets) |
| Bound tool schemas | ~100–300 tokens per tool |
| Meta-tools themselves | ~100 tokens combined (negligible) |
For a fork with 50 on-demand plugins, the cost on a turn where none are loaded is the same as a fork with zero such plugins. That's the point. On the Workers runtime (0.39.0 and later) the catalogue adds one short line per loadable plugin.
The capability router (Workers runtime)
Each load_capability call costs a model round trip. With CAPABILITY_ROUTER=on, the runtime predicts what a message needs and loads it before the first model call. From 0.39.0:
- One yes/no Decision per plugin. The router asks, for each candidate, whether this message needs it, over the message and the thread's last four visible turns. A message that needs two plugins loads both; plain chat scores low everywhere and loads nothing.
- Candidates are exactly what
load_capabilitycould load this turn: the catalogue minus what is already loaded. A plugin whose manifest setsroutable: falseis never a candidate, though it stays oneload_capabilityaway. - Each candidate is described by its
summaryplus itswhenToUselines. A plugin the router misses needs a sharperwhenToUse, not a lower threshold. - A preload is a normal load, and it is sticky. It joins
loadedPluginsfor the thread, the tool list changes once, and the plugin stays until a cold turn finds it idle. The usage and operating guide thatload_capabilitywould have returned go in a note on the turn's message. - Warm turns ask for more. On a warm turn a wrong preload costs a full prompt-cache miss, so it needs a score of
CAPABILITY_ROUTER_WARM_THRESHOLD(default 0.8) instead ofCAPABILITY_ROUTER_COLD_THRESHOLD(default 0.7).
The router fails open: a timeout (one second), a missing provider or a malformed verdict loads nothing, and the model can still call load_capability.
Loading and unloading on the Workers runtime
Any change to the tool list or the system message makes the next model call miss the provider's prompt cache, while messages appended at the end keep it. From 0.39.0 the Workers runtime therefore keeps the start of the request stable on a warm turn and saves its changes for a cold turn: the first turn of a thread, or one that opens CONTEXT_CACHE_COLD_MS (default 35 minutes) after the thread's last activity, when the cache has expired anyway.
| What | Warm turn | Cold turn |
|---|---|---|
A load (load_capability, the gate or the router) | The tool list changes once. The plugin's operating guide reaches the model at the end of the messages: in the load result, or in a note on the turn's message. | The same, and the guides of what is loaded move into the system prompt. |
| Idle plugins | Kept. | Plugins none of the last CONTEXT_PLUGIN_IDLE_TURNS (3) turns used are unloaded. |
| Old tool results | Sent as they are. | Results older than the last CONTEXT_STALE_RESULT_TURNS (2) finished turns are sent as one-line stubs. read_result reads one back whole. |
The checkpoint and the transcript always keep every tool result; only the request is trimmed. An unloaded plugin is one load_capability away, and calling one of its tools loads it again.