Skip to content

Run your own router

The router holds model credentials so no agent has to. Agents ask for a route name; the router resolves it to a provider and decides whether that caller may use it.

Run your own when you already have provider accounts and want everything in one place.

Terminal window
hexaeight-activate install-router
hexaeight-activate restart router
Terminal window
hexaeight-activate upstreams

Supported: anthropic, openai, azure, bedrock, openrouter, ollama. Each carries its own key and model list, written to upstreams.yaml beside the binary. That one file holds the upstreams and the router’s own settings — there is no second config file.

These files hold your provider keys in plain text. Treat them the way you would any secret: restrict file permissions, exclude them from version control, and keep them out of backups that travel.

Confirm what each will actually serve:

Terminal window
hexaeight-activate models

An engine sends both names in one field, separated by |:

"model": "claude-ant-aws|<the provider model id>"

The router matches the left half against upstreams.yaml to choose a provider and key, and puts the right half on the wire.

Two consequences worth understanding:

  • a route name never has to be a real model id — it is yours to name
  • a provider never sees your routing names

A route name is a glob you choose. It is matched against the left half of what an engine sends, so it never has to look like a real model id, and a provider never sees it.

A useful convention is *-<dialect>-<tag> — the dialect being the API the caller speaks (ant, oai, res) and the tag one provider and one key. Two regions of the same provider are two tags, because they are two keys. But a plain prefix works just as well: *-ant-aws and glm5br* are both valid, and a real router usually has some of each.

List what yours are called:

Terminal window
hexaeight-activate upstreams --list

The router enforces rules of the same shape as an agent’s: a sender, a destination, a direction and an effect. It has its own store, sealed under the router’s key, which the agent CLI cannot write.

A working base policy looks like this:

sender dest dir meaning
------------------------ ------------ --------- --------------------------------------------
[email protected] * inbound the owner may relay through any agent
[email protected] * outbound the owner may use any route
my-agent * inbound this agent may relay
my-agent * outbound ...using any route
second-agent * inbound a second agent may relay
second-agent *-ant-aws outbound ...but only these two routes
second-agent *-oai-aws outbound
<subject-hash> my-agent inbound a person, relaying through that agent
<subject-hash> * outbound ...may use any route

Two things to take from it:

  • inbound is “may relay”; outbound is “may use this route”. Both are needed. An agent with only an inbound row connects and can reach no model.
  • Restrict by route where it matters. second-agent above is deliberately limited to two routes. * is convenient for your own agent and wrong for anything you did not write.

A person appears as the hash of their address, not the address itself — the same subject used for vouching on the agent side.

The router prints the whole policy at startup:

Authorization: Casbin (encrypted policy at …/router-policy.he, bootstrap=False)
[policy] 9 rule(s):
[policy] sender='[email protected]' dest='*' realm='default' dir='inbound' eft='allow'
…
Callers: opaque permitted - an agent may decline to name its callers

Read three things: bootstrap=False (it is enforcing), the rule count, and the Callers line, which tells you whether requireIdentifiedCaller is in effect.

bootstrap=True, or any rule whose sender is *, means the router is open to anyone who can reach it. That is where a fresh install starts. Add your real rules and restart before exposing it.

It authorises twice per call: may this caller relay through this agent, and may this caller use this route and model. Policy matches the whole route|model string, with globs:

claude-ant-aws|* that route, any model on it
*|moonshotai.kimi-k2.5 that model, by whichever route reaches it

Globs do not cross /. A model id containing a slash needs a star per level — kimi-oai-or|*/*, not kimi-oai-or|*.

The router reads its policy once at startup. It does not reload on change — after editing, restart the router or the change is not in force.

--init-policy is how rules are written. There is no --add-rule: the store is sealed under the router’s own key, so the router itself is the only thing that can change it.

Terminal window
cd my-router
cp -p router-policy.he "router-policy.he.bak_$(date +%Y%m%d_%H%M%S)"
set -a; . ./env-file; set +a
./hexaeight-router-linux-x64 --init-policy

It shows the rules you already have, then asks five things:

it asksanswer withnotes
Do you have an external authorization service?N for most peoplesay yes only if entitlements live in your own system
Who may use this router?[email protected],my-agentcomma-separated. *@example.com admits a whole domain
Which models may they use?blank, or *-ant-aws,glm5br*blank means every model this router serves
Which agents may vouch for them?blank, or an agent nameblank means any agent
Write this policy?ynothing is written before this answer

Typing * as the sender makes it demand the literal word YES — because that is bootstrap, and leaving bootstrap is the whole reason the command exists.

A fresh router starts wide open, with two rules:

p, *, *, default, inbound, allow # anyone may reach this router
p, *, *, default, outbound, allow # ...and use any model on it

When you write your first real policy, those two are removed. The command says so before it does it. After that the router is default-deny, and every caller needs a rule.

Running it again adds to what is there rather than replacing it, so you can admit one more caller later without retyping the rest.

The router reads its policy once, at startup. After writing, restart it:

Terminal window
hexaeight-activate restart router

Then check the boot line says what you expect:

Authorization: Casbin (encrypted policy at …/router-policy.he, bootstrap=False)
[policy] 9 rule(s):

router-policy.he is encrypted and there is no decrypt tool. You read it two ways, and both print it in clear: the router prints every rule at startup, and --init-policy shows the current rules before it asks you anything — so you can run it, read them, and answer N to change nothing.

Because every call carries a proven identity, usage is attributable per caller rather than per key. That is the practical reason to run a router even with a single provider: an API key tells you what was spent; a router tells you who spent it.

There is no billing screen, and none is needed. The router appends a line per call to usage.jsonl beside the binary, and every session on the agent side leaves its own JSONL trail of what was asked and what it cost.

Terminal window
wc -l usage.jsonl # calls served
tail -1 usage.jsonl # the most recent, with its token counts

It is a plain text record, one JSON object per line, so totalling it by caller, by model or by day is whatever tool you already use — jq, a spreadsheet, or your own script. Nothing is aggregated behind an interface you cannot query.

An agent relaying on behalf of a person may send :opaque: instead of that person’s identity. The router then attributes the call to the relaying agent.

On your own router you usually do not want that — you run both sides, and per-caller accounting is the point:

requireIdentifiedCaller: true

That setting refuses opaque callers outright. It is the opposite of what an external router wants — see Forward to an external router.