Self-hosted runtime
Horai also ships as something you run yourself: an agent that holds its own wallets, signs its own transactions, and calls this platform for the parts it does not do locally. It installs as two systemd services on one Linux box.
This is a different product surface from the hosted app. Nothing here talks to your account in the browser — the connection between them is a gateway key you paste in, and the build tool that key unlocks.
Two processes, and why it is two
horai-signer holds the keys, enforces the policy, signs
horai-agent runs the model loop, the sandbox, the channels
They run as two separate OS accounts that cannot read each other's files, and they speak over a unix socket owned by a shared group. The split is the whole design:
- The signer has no network. Its unit sets
RestrictAddressFamilies=AF_UNIX, so the kernel refuses to give it a TCP socket at all. Even fully compromised, the process holding your keys has nowhere to send them. - The agent has no keys. Its unit sets
ProtectHome=true, so it cannot read the signer's home directory — where the encrypted key material lives — and the filesystem permissions say the same thing a second time. - The agent cannot restart the signer. It depends on it and cannot control it.
The agent is the part that reads untrusted input: model output, web pages, messages from a channel. It is arranged so that being wrong there costs you an API bill rather than a wallet.
What the signer will and will not do
The socket exposes a fixed set of operations. Everything else is not disabled by permission — it does not exist in the process:
operations that exist operations that do not exist, at any permission level
getAddress exportKey
createWallet exportPrivateKey
signTransaction.transfer getPrivateKey
signTransaction.swap revealMnemonic
signTransaction.deployToken dumpKeystore
useTool.build signRaw
useTool.read
signRaw is absent on purpose. An agent that can sign arbitrary bytes can sign anything, and every cap above it becomes decoration.
Key export exists, as a command a person runs on the box. It is not reachable from the agent, from any channel, or from any model tool — the module that performs it is not linked into the process that serves the socket. Run horai doctor on your own machine and it prints these lists, so you can check the claim rather than trust it.
Install
Two lines, as root, on a fresh Linux box:
curl -fsSO https://horai.sh/horai.tar.gz
mkdir -p /opt/horai && tar -xzf horai.tar.gz -C /opt/horai && bash /opt/horai/packaging/install.sh
Unpack into /opt/horai rather than wherever you are standing. The signer runs as its own account and every directory above the code has to be traversable by it — a root shell starts in /root, which is mode 0700. The installer checks this by having each account actually open a file, and refuses with the reason rather than leaving you a service that crash-loops while systemctl reports it active.
It creates the two accounts, the shared group, the key directory, the run directory and both units, then prints the next steps. It does not start anything.
First run
Set the key-encryption key and start the signer. The key that encrypts your wallet keys lives in a systemd drop-in, not in a file in the repository:
systemctl edit horai-signer.service
[Service]
Environment=HORAI_SIGNER_KEK=<32 random bytes, hex>
systemctl enable --now horai-signer.service
Then run the guided setup, which creates the seed, shows it once, and asks for your caps, your allowlist and your permissions:
sudo -u horai-signer HORAI_SIGNER_KEK=<your-kek> HOME=/home/horai-signer horai setup
The sudo -u prefix matters. The CLI opens the signer's database directly and that database lives in the signer's home — run it as root and you quietly configure a different one under /root, then enforce the other. Commands that only read the machine's layout, like doctor, do not need it.
horai doctor
doctor prints the accounts, the key directory and its mode, whether the socket exists, whether the signer answers, and the two operation lists above. On a fresh install it reports the socket as not found until you start the service, which is the correct answer rather than an error.
Giving it a model
The agent reads its inference keys from a drop-in on its own unit:
systemctl edit horai-agent.service
[Service]
Environment=HORAI_API_KEY=horai_your_gateway_key
That is a gateway key from this platform, and it gives the runtime the same router the hosted chat uses, billed to the same balance. Your own provider key works instead, and the two can coexist:
Environment=HORAI_ANTHROPIC_API_KEY=sk-ant-...
Never put HORAI_SIGNER_KEK in the agent's drop-in. A leaked inference key costs an API bill; the key-encryption key costs the wallet.
HORAI_API_BASE points the runtime at a different platform origin. It accepts https anywhere and http only on loopback, and a value it will not accept is refused rather than quietly ignored — falling back would send your key somewhere you did not choose.
Policy
The policy is what the signer enforces before it signs. You choose it during setup, and you read it back with:
sudo -u horai-signer HORAI_SIGNER_KEK=<your-kek> HOME=/home/horai-signer horai policy show
It prints in two groups, and the grouping is the point:
- Caps enforced on the transaction itself. Native value, gas, and fees, read by the signer out of the bytes it is about to sign. These hold even if the agent is entirely compromised.
- Caps enforced on a number the agent declares. Per-transaction and rolling daily USD. The signer has no price feed and cannot check them, so they protect you from an honest agent's mistakes and not from a dishonest one. Set the first group as your real limit.
Below those sit the permission switches — signing, transfers, swaps, token deploys, wallet creation, tool use — and a recipient allowlist. A fresh install has every cap at zero, every permission off, and an empty allowlist, so it can do nothing until you decide otherwise.
Two rules shape how it changes:
- Until one transaction lands, caps may only go down. You cannot raise a limit you have never exercised.
horai policy lockfreezes it. After that the running agent can no longer alter its own constraints.
The build tool
With a gateway key configured, the runtime can build and deploy an app the same way the hosted chat does. A sentence goes in, the job streams back, and the source archive lands in the working directory:
▸ proposal e970dfbd-86b4-4e51-9eed-8f904eb174e7
▸ queued
▸ executing
▸ settled
▸ saved splitbill-source.zip (20796 bytes)
▸ live https://apps.horai.sh/splitbill/
The tool is governed by the policy — it runs only when tool use is permitted, and the signer decides that over the socket, in the other process. It also cannot reach the signer at all: it carries fetch, a place to print, and nothing else. That is enforced by a test that walks its import graph and fails if the signer client is reachable through any path, because everything this tool touches — the sentence, the job's status strings, the URLs in the response — arrives from outside.
Cross-chain routes and refundTo
Routes that cross chains go through Relay, and every quote the runtime requests sets refundTo to an address on the origin chain.
It is required rather than optional here. Without it the route falls back to the recipient, which is an address on the destination chain — and a route that fails after the origin leg would refund to a chain the money never reached. The address kind is validated against the origin chain before the quote is requested, so a Solana address on an EVM origin is rejected at the call rather than discovered during a refund.