Why the External Controller API matters for node automation

A Clash profile can describe a sophisticated proxy-group strategy, but declarative YAML alone does not always answer the operational question: “Which node should I use right now, and what should happen when it becomes unhealthy?” A selector group gives you manual control, while url-test and fallback groups perform built-in health decisions. Those features are useful, yet production environments often need additional logic: exclude nodes with a specific label, prefer a region during business hours, avoid a provider whose recent latency is unstable, or notify an administrator when every candidate fails.

This is where the Clash External Controller API becomes practical. Mihomo and several Clash-compatible clients expose an HTTP interface that lets an authenticated local program inspect proxies, query group membership, run delay tests, and change the active member of a selector group. Instead of opening the GUI and clicking through a list after every outage, you can build a small, auditable workflow around the same control plane. The automation does not need to rewrite your entire YAML file; it can make a narrow runtime decision and leave the profile itself unchanged.

The distinction between configuration and runtime state is important. YAML defines the available nodes, groups, rules, listeners, and defaults. The external controller reports what the running core currently knows and accepts commands such as selecting a proxy or requesting a latency measurement. If you refresh the profile, restart the core, or switch to another profile, a runtime selection may be replaced by the profile’s default. Treat the controller as an operational interface, not as a permanent configuration database.

The API is also not a magic reliability layer. It cannot repair an expired subscription, a broken DNS resolver, a provider-side outage, or a local port collision. It can measure and react to those conditions, but the quality of the result depends on your probe URL, timeout, candidate filtering, and failure policy. A useful automation script therefore begins with observability and conservative decisions rather than immediately rotating nodes on every slow response.

Scope: Keep the controller bound to localhost or a protected management network. Use a strong secret, follow your organization’s access rules, and automate only traffic that you are authorized to route through the selected providers.

Enable a secure controller and design the group

Before writing Python, inspect the running client’s configuration and determine which API address it actually exposes. A common Mihomo-style configuration looks like this:

external-controller: 127.0.0.1:9090
secret: "replace-with-a-long-random-secret"

Some clients place these fields in an advanced settings panel, while others generate them from a profile or expose them through a separate controller setting. The exact menu differs between Clash Verge, Clash Verge Rev, Mihomo Party, Clash for Android, and other clients, so verify the effective runtime configuration rather than assuming that a saved YAML file is active. If the controller is disabled, an API request will fail even when the proxy core itself is working normally.

Binding to 127.0.0.1 is the safest starting point for a script running on the same machine. Binding to 0.0.0.0 makes the service reachable from other interfaces and should be treated as a sensitive administrative exposure. A controller secret sent over an untrusted network can allow another party to inspect proxy metadata or change the route for every application using the core. If remote administration is necessary, place the endpoint behind a firewall, an authenticated management tunnel, or a trusted private network, and do not rely on an obscure port number as protection.

Next, create a dedicated group whose name is stable and easy to identify. Avoid selecting a broad group such as GLOBAL if your automation is intended only for model APIs, work tools, or a particular ruleset. A narrow group makes the effect of a runtime API call predictable:

proxy-groups:
  - name: AUTO-WORK
    type: select
    proxies:
      - NODE-A
      - NODE-B
      - NODE-C
      - DIRECT

rules:
  - DOMAIN-SUFFIX,example-service.test,AUTO-WORK
  - MATCH,PROXY

The group must be a type that accepts an explicit member selection. A select group is the clearest target because the script chooses one child directly. An automatically evaluated url-test group may change its own decision after the script runs, which can make a manual API selection appear to “revert.” A fallback group also has its own health semantics and may not be the right place to impose application-specific ranking. If you want built-in measurement and external policy together, let the built-in group handle basic availability and use a separate selector for the automation layer.

Keep node names stable whenever possible. Subscription providers frequently regenerate names, append emojis, or change numeric suffixes. A script that expects an exact name such as Tokyo 01 will fail after a harmless provider rename. Prefer a naming convention that includes a predictable region or role, or select candidates by the API’s returned metadata and a normalized matching rule. Always log the final chosen name so a later incident review can tell whether the script selected the intended member.

Understand proxies, groups, delays, and selection requests

The most useful controller endpoints expose four related kinds of information. A request to the proxies resource returns the proxies and proxy groups known by the running core. A request for an individual group reveals its current member and its available members. A delay request measures a named proxy against a URL with a timeout. A selection request changes the active member of a selector group. Endpoint names and response fields can vary slightly across Clash-compatible cores, so test the installed version with a harmless read request before building assumptions into a fleet script.

A typical inspection request uses the controller base URL and an authorization header:

GET http://127.0.0.1:9090/proxies
Authorization: Bearer replace-with-a-long-random-secret

The response normally contains a top-level object of proxies. A group entry may include a type, a list of all members, and a current now value. Individual node entries can expose a type, history, extra provider metadata, or fields that differ between core versions. Do not assume every member is a directly testable server. A group may contain DIRECT, REJECT, another nested group, or a node whose provider has temporarily removed its endpoint.

Delay testing should use a URL that represents the traffic you actually care about. A tiny static endpoint is useful for detecting basic reachability, but it may not predict the behavior of a long-lived API stream, a large download, or a service hosted in another region. Conversely, testing a complex application URL can introduce redirects, authentication, rate limits, and content filtering that make the result difficult to interpret. Choose a stable HTTPS endpoint, document it, and use the same endpoint when comparing candidates.

Latency is only one signal. A node with a 180 millisecond response may be more useful than a 70 millisecond node that fails one request out of three. The controller’s delay result is a measurement at a particular moment, not a guarantee for the next connection. A reliable policy should combine a latency threshold with a success requirement, a small number of retries, and a cooldown period. If you switch nodes too aggressively, active applications can experience repeated connection churn and the system can oscillate between two equally imperfect candidates.

Signal What it tells you What it does not prove
Delay in milliseconds How quickly a probe completed from the current core That streaming, uploads, or future requests will remain healthy
HTTP success That the probe reached an acceptable response state That the destination application or account will accept every request
Current group member Which child the running selector currently uses That existing connections have migrated to the new child
Recent failure count That a candidate has behaved poorly during your observation window That the provider is permanently unavailable

Build a cautious Python failover script

The following example uses the standard library so it can run from a small server, scheduled job, or container without adding a large dependency tree. It reads the current group, filters its members, requests a delay measurement for each candidate, and changes the selector only when the best result is meaningfully better than the current state. The example intentionally avoids nested groups and special members; those cases should be handled explicitly in a production implementation.

import json
import os
import time
import urllib.parse
import urllib.request

CONTROLLER = os.getenv("CLASH_CONTROLLER", "http://127.0.0.1:9090")
SECRET = os.environ["CLASH_SECRET"]
GROUP = os.getenv("CLASH_GROUP", "AUTO-WORK")
PROBE_URL = os.getenv("CLASH_PROBE_URL", "https://www.example.com/generate_204")
TIMEOUT_MS = int(os.getenv("CLASH_TIMEOUT_MS", "5000"))
MAX_DELAY_MS = int(os.getenv("CLASH_MAX_DELAY_MS", "900"))
MIN_IMPROVEMENT_MS = int(os.getenv("CLASH_MIN_IMPROVEMENT_MS", "80"))

HEADERS = {"Authorization": f"Bearer {SECRET}"}

def request_json(path, method="GET", payload=None):
    body = None
    headers = dict(HEADERS)
    if payload is not None:
        body = json.dumps(payload).encode("utf-8")
        headers["Content-Type"] = "application/json"

    req = urllib.request.Request(
        CONTROLLER + path,
        data=body,
        headers=headers,
        method=method,
    )
    with urllib.request.urlopen(req, timeout=12) as response:
        return json.loads(response.read().decode("utf-8"))

def get_group():
    encoded = urllib.parse.quote(GROUP, safe="")
    return request_json(f"/proxies/{encoded}")

def test_proxy(name):
    proxy = urllib.parse.quote(name, safe="")
    target = urllib.parse.quote(PROBE_URL, safe="")
    path = f"/proxies/{proxy}/delay?url={target}&timeout={TIMEOUT_MS}"
    try:
        result = request_json(path)
        return int(result["delay"])
    except Exception:
        return None

def main():
    group = get_group()
    current = group.get("now")
    candidates = [
        name for name in group.get("all", [])
        if name not in {"DIRECT", "REJECT", GROUP}
    ]

    measurements = {}
    for name in candidates:
        delay = test_proxy(name)
        if delay is not None and delay <= MAX_DELAY_MS:
            measurements[name] = delay

    if not measurements:
        raise RuntimeError("No candidate passed the probe")

    best = min(measurements, key=measurements.get)
    best_delay = measurements[best]
    current_delay = test_proxy(current) if current else None

    should_switch = (
        current not in measurements
        or current_delay is None
        or best_delay + MIN_IMPROVEMENT_MS < current_delay
    )

    if should_switch and best != current:
        encoded_group = urllib.parse.quote(GROUP, safe="")
        request_json(
            f"/proxies/{encoded_group}",
            method="PUT",
            payload={"name": best},
        )
        print(f"switched {current!r} -> {best!r} ({best_delay} ms)")
    else:
        print(f"kept {current!r}; measurements={measurements}")

if __name__ == "__main__":
    main()

Treat this script as a pattern rather than a universal endpoint contract. Some cores use a delay endpoint that expects a URL and timeout in query parameters, while a fork or older release may return a different JSON shape. First run the read-only group request, then test one known node manually, and record the HTTP status and response body. If the selection request returns a client error, inspect whether the target is really a selector group and whether the selected name appears in its current all list.

The filtering rules deserve as much attention as the HTTP code. The script excludes special members, rejects results above a maximum delay, and requires a minimum improvement before switching. Those three choices prevent common incidents: selecting DIRECT when the goal is a managed proxy, choosing an unstable high-latency node merely because it answered once, and changing routes every minute because two candidates differ by only a few milliseconds. For a larger fleet, add a failure counter, an exponential backoff, a lock file, and a persistent record of the last switch time.

Handle authentication, errors, and stale state

Never hard-code the controller secret in a repository or paste it into a command that will be saved in shell history. Supply it through an environment variable, a protected service file, or a secret manager. The script should distinguish authentication failures from network failures: a 401 or 403 indicates a credential or access-policy problem, while a connection refusal usually means the core is stopped, listening on another address, or blocked by a local firewall.

A successful selection response does not mean every application immediately uses the new node. Existing TCP connections can remain attached to the old outbound path until they close. Long-lived WebSocket or HTTP/2 sessions may therefore continue behaving differently from newly created requests. When failover is safety-critical, alert the operator and let the application retry through a fresh connection instead of pretending that one API call migrated every session.

Also account for profile refreshes. A subscription update can remove a node between the moment you retrieve all and the moment you send the selection request. If the request fails because the candidate disappeared, reload the group, discard the stale name, and begin a new measurement round. Avoid an infinite loop: two or three attempts followed by a visible alert is safer than a script that continuously hammers the controller while the profile is being replaced.

Connect the workflow to monitoring, cron, and containers

A failover script becomes useful only when its execution model matches the failure you want to address. A cron job running every five minutes is adequate for slow degradation and keeps API traffic modest. It is not a substitute for application-level retry logic during a sudden outage. For a workstation, a systemd timer, launchd job, or scheduled task can run the script with a restricted environment. For a server, a small monitoring service can invoke the check after several consecutive probe failures rather than changing routes on a fixed clock.

Add structured logs with a timestamp, group name, current member, candidate results, selected member, and reason for the decision. Do not log controller secrets, subscription URLs, authorization headers, or full URLs that may contain tokens. Useful events include probe_failed, candidate_rejected, selection_changed, and selection_skipped. These labels let an alerting system distinguish “all nodes are slow” from “the script lost access to the controller.”

A simple cron entry might look like this:

*/5 * * * * /usr/local/bin/clash-node-switcher >> /var/log/clash-node-switcher.log 2>&1

In practice, use an absolute interpreter path, a dedicated virtual environment if dependencies are added, and a protected environment file rather than assuming cron inherits your interactive shell. Set a timeout for the whole process so a stuck probe cannot overlap with the next run. A lock mechanism is especially important when a previous run tests many candidates sequentially.

Containers require an extra layer of planning. A container on the same Docker network can reach a controller bound to the host only if the host address is deliberately exposed; 127.0.0.1 inside the container means the container itself, not the host. You can run the automation in the same network namespace as the Clash core, publish the controller on a private bridge address, or use a host gateway feature supported by your platform. Do not publish the controller directly to the public internet merely to make a container request convenient.

Health checks should measure more than the script’s process status. Export metrics such as the selected node, last successful probe time, number of candidates, switch count, and consecutive failed rounds. Alert when every candidate fails, when the same node changes repeatedly within a short window, or when the controller has not responded for a defined interval. A quiet script that is unable to authenticate is more dangerous than a noisy script that reports the problem immediately.

Production policy: avoid flapping and preserve intent

Node switching can conflict with application requirements. Banking, ticketing, enterprise identity, and allowlisted services may expect a stable egress identity. A lower latency node is not automatically the correct node if it changes geography, address reputation, or provider ownership. Keep sensitive domains on a deliberately selected group, and apply automation only to traffic whose availability benefit outweighs the cost of changing the outbound identity.

Use hysteresis to prevent flapping. One practical policy is to keep the current node unless it fails two consecutive probes, or unless a replacement is faster by a substantial margin for two complete measurement rounds. Add a cooldown after every switch, and do not select a candidate that failed recently unless all healthier options are unavailable. If your provider exposes many nodes, test a bounded sample instead of generating dozens of requests every minute.

Separate “reachable” from “usable.” A probe may return quickly while the node is overloaded, rate-limited, or unable to maintain the protocol required by your application. Where permitted, pair a generic connectivity probe with a lightweight service-specific check that does not expose credentials or consume billable quota. Document the limitations of each signal so an operator does not interpret a green dashboard as a complete end-to-end guarantee.

Keep a manual escape hatch. The GUI should still allow an operator to select a known-good node, disable the scheduled job, or move traffic to a maintenance group. Expose configuration through environment variables or a small local file rather than burying thresholds in code. During an incident, the fastest safe action is often to freeze the current route and investigate, not to allow an automation loop to make a dozen undocumented changes.

Finally, test the recovery path before calling the workflow reliable. Stop the controller and confirm that the monitor raises an alert without leaking the secret. Make one candidate return a timeout and confirm that it is rejected. Remove the selected node through a profile refresh and verify that the script re-reads the group. Restore the service and check that the next selection is logged, bounded by the cooldown, and visible in the client UI. These controlled failures reveal more than a successful run against an always-healthy subscription.

Compared with manual switching in Clash for Windows, Clash Verge, or a browser-based dashboard, a small External Controller workflow gives you repeatable measurements, explicit thresholds, audit logs, and a clear separation between profile data and runtime decisions. Generic VPN clients may offer a one-click “fastest server” button, but they often hide the probe target, selection policy, and failure history, making it difficult to explain why a route changed or why a service broke. Clash V.CORE is a better fit when you need transparent groups, API-accessible state, Mihomo-compatible controls, and automation you can inspect and adapt. If you want to turn the examples in this guide into a dependable node failover setup, visit the Clash V.CORE download page and choose the client that matches your operating system and management workflow.

// Editor's Pick

Clash V.CORE for API-driven node switching

Build a visible, scriptable failover workflow with stable groups, runtime control, and Mihomo-compatible routing features.

  • External Controller API access
  • Selector groups for precise failover
  • Latency testing with custom probes
  • Transparent logs and runtime state
  • Profile-based node organization
Get Clash V.CORE →