Skip to main content

Delegation Provider Plugins

A delegation provider decides how each delegate_task task becomes a child run: which model, provider, endpoint and reasoning effort the subagent uses, or which Hermes profile it runs as. It can also add per-task parameters to the delegate_task schema, such as an enum of profile names or of titled model options, so the model picks a route per task.

Hermes core keeps the whole subagent lifecycle: spawning, isolation, leaf/orchestrator roles, depth limits, concurrency, background completion units, stop fan-out, result assembly, output_schema validation and progress. A provider only answers "which route does this task take".

The active provider is delegation.backend in config.yaml; a provider's own settings live under delegation.backends.<backend name>. The default strategy is today's behaviour: every child follows the delegation section (delegation.provider, delegation.model, delegation.base_url, ...), or inherits the parent's route when those are empty. It is itself a plugin, the bundled plugins/delegation/default/ (kind: backend, always loaded), registered through the same ctx.register_delegation_provider() seam as yours; it is also the smallest complete example. A configured backend that is not installed or reports unavailable fails closed: delegate_task returns a tool error naming the backend and the fix, no child starts, and its schema parameters are absent. A session can override the backend (agent.delegation_backends, below).

Skeleton​

# ~/.hermes/plugins/delegation-tiers/__init__.py
from agent.delegation_provider import DelegationProvider, DelegationRouteError


class Tiers(DelegationProvider):
@property
def name(self) -> str:
return "tiers" # delegation.backend: tiers

def task_parameters(self, config):
tiers = self.settings(config).get("tiers") or [] # delegation.backends.tiers.tiers
return {"tier": {
"type": "string",
"enum": [t["name"] for t in tiers],
"description": "; ".join(f"{t['name']}: {t['description']}" for t in tiers),
}}

def description(self, config):
return "Set `tier` per task to pick the model that fits it."

def route_task(self, task, config):
for t in self.settings(config).get("tiers") or []:
if t["name"] == task.get("tier"):
return {"provider": t["provider"], "model": t["model"], "label": t["name"]}
if task.get("tier"):
raise DelegationRouteError(f"Unknown tier {task['tier']!r}.")
return dict(config) # no tier: the default route


def register(ctx):
ctx.register_delegation_provider(Tiers())
# plugin.yaml
name: delegation-tiers
version: 1.0.0
description: Pick a model tier per subagent.
# config.yaml
delegation:
backend: tiers
backends:
tiers: # == the backend name
tiers:
- {name: quick, description: Lookups and summaries., provider: openrouter, model: google/gemini-3-flash-preview}
hermes plugins enable delegation-tiers

The interface​

DelegationProvider (agent/delegation_provider.py) subclasses the shared ProviderBase. Every method receives config, the delegation section of the active profile's config.yaml: dict(config) is the default route, and self.settings(config) is your backend's own block, delegation.backends.<name> (empty when unset). Keep your settings there, never beside the core delegation keys.

MemberRequiredContract
nameyesThe delegation.backend value. default belongs to the bundled default strategy.
route_task(task, config)yesReturn the route dict for one task. task carries goal, context, index and the values the model supplied for your parameters. Raise DelegationRouteError to refuse; its message is the tool error the model reads.
task_parameters(config)no{name: json_schema} merged into tasks.items.properties. Core enforces enum before route_task runs and refuses core-owned names (goal, context, index, output_schema, images, group, role, toolsets, provider, model, base_url, api_key, api_mode, acp_command, acp_args).
description(config)noGuidance appended to the delegate_task description.
is_available()noNo network calls. False fails delegation closed while your backend is selected: delegate_task returns a tool error naming it and the fix (/delegation default, or hermes config set delegation.backend default); it never switches to default on its own.
settings(config)inheriteddelegation.backends.<name> as a mapping (empty when unset). Do not override.

Routes​

A route is a dict with any of:

  • model, provider, base_url, api_key, api_mode, request_overrides, reasoning_effort, fallback_providers: shaped exactly like the delegation section and resolved by the same credential resolver. An empty route inherits the parent's route; dict(config) is the default route. A reasoning_effort that is not a known level (none, minimal, low, medium, high, xhigh, ...) refuses the task with a tool error instead of falling back to the parent's level; the delegation.reasoning_effort value itself keeps its usual warn-and-inherit rule.
  • service_tier: normal, fast or ultrafast. Core checks it against the resolved route with the same gates as /fast (and, for ultrafast, the signed-in Codex account's catalog). A tier the route does not offer refuses the task; otherwise it applies to that child only and is re-applied after the child is built, so a custom provider's extra_body cannot restore another tier.
  • profile: the name of a Hermes profile. The child runs as that profile: its config.yaml, primary model and credentials (a model.default alias or named provider resolves exactly as at CLI startup), credential pool, fallback chain, reasoning (agent.reasoning_effort and agent.reasoning_overrides), skills, SOUL.md and terminal policy. Core binds that profile's scope (HERMES_HOME override, secret scope, multiplex semantics, terminal policy) around the child's construction, its credential lease and every conversation turn, the same binding a multiplexed gateway applies to a routed turn, so a secret missing from the target profile is never read from the launching profile's environment. The child's transcript stays in the launching profile's session database (it is the parent's work) and it keeps the parent's toolset. Other route keys are ignored on a profile route.
  • label: short text identifying the route (an option title, a profile name).

Unknown keys are dropped. On a profile route, service_tier and reasoning_effort are ignored too.

A non-profile route without fallback_providers of its own gets delegation.fallback_providers; return "fallback_providers": [] to disable fallback for a route. A profile route keeps its profile's chain.

Refusals are per task​

A DelegationRouteError from route_task, a parameter your backend did not declare, a value outside your parameter's enum, an unknown reasoning_effort, a tier the route does not offer, or a credential the route cannot resolve refuses that task only: no child is built for it, its result entry is {"status": "error", "error": "<your message>", "route": {"backend": "<name>", "refused": true}}, and the other tasks run. A background call lists refused entries under refused in its dispatch payload. The call is a tool error only when every task is refused.

Result route​

Every result entry of a task routed by a non-default backend carries route: the backend, the label (when set), and what the child actually ran on, read off the child when its result is assembled:

"route": {"backend": "model_map", "label": "deep", "provider": "openai-codex", "model": "gpt-6-astra",
"reasoning_effort": "high", "service_tier": "fast"}

reasoning_effort is null when the provider's default applies; fallback_activated: true is added when a fallback provider took over. The default backend's children leave the result entry unchanged.

Picker metadata and session switching​

display_name and summary (one user-facing line; never shown to the model) label your backend in pickers such as /delegation. A plugin uses ctx.delegation_backends (same methods; list_backends() returns plain dicts). It is a host remote facade, so under plugins.isolation: host it acts on the Hermes process, not the plugin host. Core code calls agent.delegation_backends directly:

CallDoes
list_backends() -> list[BackendInfo]default first, then every registered backend: name, label, description, available, reason (the fix, when unavailable).
get_session_backend(session_key) -> str | NoneThe session's override, None when it follows config.yaml.
set_session_backend(session_key, name | None)Switch one session (None clears). Raises ValueError with the reason for a backend that cannot run.
set_global_backend(name, *, session_key="")Write delegation.backend to the active profile's config.yaml and drop that session's override. Raises PermissionError on a package-manager-managed install.

A plugin command that edits model routes can reuse the interactive model picker under its own heading: hermes_cli.auth_model_picker.prompt_model_selection(model_ids, current_model="", title="Select the model for this route:") returns the chosen model id, or None when cancelled.

session_key is the session_key a plugin command handler receives. A session switch decides routing from that session's next delegate_task call; the delegate_task schema (your parameters) follows config.yaml, because it is part of the cached prompt and is never rebuilt mid-conversation. After a switch the model can still send the previous backend's parameters, so core refuses each task carrying a parameter the session's backend does not declare (fail closed per task, like any other refusal) instead of dropping it and running the task on the new backend's default route:

Task 0: this session's delegation backend is 'profile_routes', which does not take 'option' (declared by
'model_map'), so this task was not started. Run `/delegation profile_routes --global` then `/new` for a
session whose delegate_task choices match 'profile_routes', or `/delegation model_map` to switch this
session back to the saved backend.

/new starts on the saved backend (delegation.backend), so the fix persists the session's pick first or names the saved one. When the session follows config.yaml (it changed mid-session), the fix is just /new. A parameter no installed backend declares is refused the same way, with "Resend it without ..." first.

Prompt caching​

The delegate_task definition, including your parameters and description, is built once when a session's tool list is assembled. task_parameters and description must be deterministic for a given config: same config, same bytes. Never derive them from the clock, the network or anything that changes during a conversation.

Process isolation​

Every argument and return value is plain JSON, so a provider runs unchanged under plugins.isolation: host: Hermes holds a proxy that subclasses DelegationProvider and every call runs in the plugin host process.

Testing​

Load the plugin through the real discovery path against a temporary HERMES_HOME whose config.yaml sets plugins.enabled, delegation.backend and delegation.backends.<name>, then assert on behaviour: the delegate_task definition from tools.registry.registry.get_definitions({"delegate_task"}) carries your parameter, and tools.delegate_tool.delegate_task(...) hands each child the route you chose. tests/tools/test_delegation_backends.py in the Hermes repo is a complete example.