Share a GPU, borrow a GPU.
A bothy is a small unlocked shelter in the Scottish hills. Nobody owns it, nobody staffs it, nobody pays to sleep in it — volunteers keep it standing so that whoever needs it can use it. That is the model here: people with spare GPU capacity make it available to people who have none, for free, and Bothy handles everything in between.
You have a GPU and a model running in Ollama (or llama.cpp, or vLLM). A friend doesn't. They install Bothy, point their OpenAI client at Bothy's port, and their tokens run on your GPU. No weights move. No model download on their side.
Your own engine is not touched, not reconfigured, and not impersonated. Ollama keeps its port, Bothy has its own, and the two sit side by side — so the machine that shares a model can use somebody else's in the same session.
| PROTOCOL.md | The wire contract. Everything speaks this, so anything can implement it |
| docs/design.md | Why it is shaped this way, and what is still open |
| CONTRIBUTING.md | Setup, layout, and the ground rules |
| SECURITY.md | The threat model, and how to report a problem |
| CHANGELOG.md | What landed in each release |
No account, no installer, no build step, and nothing to install: Bothy is Python with no dependencies outside the standard library, so the source tree is what runs.
git clone https://github.com/hibbault/bothy
cd bothy
# or the container, to run beside an existing Ollama
docker build -t bothy .
docker run --network host -e BOTHY_ENGINE_URL=http://localhost:11434 \
-e BOTHY_SHARE_KEY=a-secret bothy sharePython 3.9 or newer, and nothing else — no dependencies, no compilation, no
pip install. That is deliberate. A host runs on whatever machine already has
the GPU, and installing something should not be the step between a person and
sharing it.
One command runs the whole thing and picks the roles for itself:
python -m bothy run -share-key a-secret -discovery-url http://<registry>:8080If a local engine answers with a model, that machine shares it and borrows; if none does, it only borrows. The rest of this file names the two halves explicitly, because a log line that belongs to one role is easier to read:
python -m bothy share -engine-url http://localhost:11434 -share-key a-secret
python -m bothy connect -discovery-url http://<registry>:8080 -model llama3.1:8b -share-key a-secretpython -m bothy version says which release you are running. A release is a tag
rather than an artifact: what runs is the source tree, so there is nothing to
verify beyond the commit you are on.
Client machine Host machine (GPU)
your OpenAI client inference engine
│ (Ollama / llama.cpp / vLLM)
▼ ▲
127.0.0.1:11223 ──── network ───────► :7777 ────────┘
bothy connect bothy share
Two roles, one command:
-
Host (
python -m bothy share) runs next to your engine and proxies to it. It does not touch your models and never downloads anything. It announces what you can serve, requires a share key, meters who uses what, and caps how much of your GPU any one peer can occupy. -
Client (
python -m bothy connect) opens a local, OpenAI-compatible endpoint. Point anything at it and the tokens run elsewhere:curl http://127.0.0.1:11223/v1/chat/completions \ -H 'content-type: application/json' \ -d '{"model":"llama3.1:8b","messages":[{"role":"user","content":"hi"}]}'
And one command runs both, deciding for itself which the machine can be:
python -m bothy runThree ports, one binary, none of them stolen:
| Port | What it is |
|---|---|
11223 |
the client's local endpoint — how you borrow |
7777 |
the host — how others reach your engine |
11434 |
your engine's own port, untouched |
They are different on purpose. Serving and borrowing at the same time is an
ordinary thing to want — your own model locally, somebody else's for what your
GPU cannot hold — and two roles on one number cannot do that. Bothy also never
pretends to be an engine: your editor keeps talking to Ollama where it always
did, and the tools that should use the fleet are pointed at 11223 rather than
told a lie about where the model is.
The devnet runs all four pieces in containers, with a fake engine standing in for real inference:
make devnet # or: COMPOSE_PROFILES=mock docker compose up --buildWindows without make (PowerShell, Docker Desktop running):
$env:COMPOSE_PROFILES = "mock"; docker compose up --builddiscovery :8080 registry — hosts announce, clients look up
engine-mock :11434 a fake engine that speaks enough OpenAI/Ollama to be useful
host :7777 shares the fake GPU
client :11223 borrows it; published on 127.0.0.1:11223 for you
Then talk to somebody else's GPU:
curl http://127.0.0.1:11223/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"llama3.1:8b","messages":[{"role":"user","content":"who answered this?"}]}'The reply names the engine and the digest that produced it, so you can always tell whose GPU actually answered. Ask the client where it ended up:
curl http://127.0.0.1:11223/bothy/statusTo use a real GPU instead, make real and pull a model into the Ollama container.
The three containers exist so the interesting failures are reproducible, not just the happy path:
| Command | What it proves |
|---|---|
docker compose stop host |
after the registry TTL, the client stops being handed a dead host |
make mismatch |
a second host serves llama3.1:8b with different weights, and the client refuses it |
docker compose stop discovery |
already-connected clients keep working; only new lookups fail |
docker compose --scale client=5 up |
five clients against one host's cap — you should see refusals, not a pinned GPU |
The mismatch run is the important one. It is the only way to test digest verification without downloading the same 8GB model twice:
# Point a throwaway client at the host whose weights are NOT the ones expected.
docker compose run --rm client connect \
-host http://host-b:7777 -model llama3.1:8b \
-expected-digest sha256:1111111111111111111111111111111111111111111111111111111111111111
# → refuses to start: digest mismatchllama3 on your machine and llama3 on mine can be different quantizations,
different fine-tunes, or different files entirely. So a model is identified by
the SHA-256 of its weights file, not its tag.
How the host finds the digest
- Ollama: the manifest under
~/.ollama/models/manifests/…has a layer with mediaTypeapplication/vnd.ollama.image.modelwhose digest is the SHA-256 of the weights blob. PointBOTHY_MODELS_DIRat the models directory and Bothy reads it — no gigabyte hashing. - Ollama caution: the
digestin/api/tagsis the manifest digest, which changes whenever any layer does. It is not the weights hash, so Bothy prefers the layer digest when it can read the manifest. - llama.cpp / vLLM: these report no digest at all. Either list them explicitly
(
BOTHY_MODELS="my-model=sha256:…") or hand Bothy the weights file (BOTHY_WEIGHTS_PATH=…) and it hashes it, caching the result against the file's size and mtime.
How the client uses it: give it BOTHY_EXPECTED_DIGEST and a host offering
anything else is refused. Leave it unset and any host is accepted, which is the
sane default — most people just want a working model.
What a digest does and does not prove. It catches honest mismatches: wrong tag, wrong quant, stale pull. It does not prove the host actually ran those weights — a dishonest host can report any digest it likes. Really proving it needs deterministic re-sampling or attested hardware. That is an open problem here, not a solved one.
A share key keeps strangers out. It says nothing about how much anyone is using, though, so the host counts: requests, tokens in, tokens out, response bytes and refusals, per peer.
curl -H 'X-Bothy-Key: key-a' http://localhost:7777/bothy/usage{ "in_flight": 1, "free": 3, "max_concurrent": 4, "owner_reserve": 1,
"peer_slots": 3, "peer_quota": "200/1h", "paused": false,
"peers": [
{ "peer": "alice", "requests": 3, "prompt_tokens": 6,
"completion_tokens": 15, "limited": 0,
"unmetered_responses": 0, "response_bytes": 1476,
"quota_used": 3, "quota_reset": "2026-09-14T11:01:44Z" }
]
}The names come from per-peer keys:
python -m bothy share -share-keys alice:key-a,bob:key-b -max-concurrent 4Without them you get one row per caller address. That is still enough to see that someone is pinning the GPU — just not who.
Six knobs, all optional, and the first is on by default. They are what a public host has instead of a bouncer: identity does not bound what a stranger can cost you, limits do.
BOTHY_OWNER_RESERVE(default 1) keeps that many ofBOTHY_MAX_CONCURRENTout of peers' reach, so your own request never queues behind strangers. Bothy's own traffic never passes through the host and no portable engine API reports whether an engine is busy, so this is not a reading of what you are doing — it is a guarantee of headroom, which is the only honest shape available for it.BOTHY_MAX_CONCURRENT(default 4) caps requests in flight across the whole host. A GPU serialises work anyway, so this is the limit that actually protects it. Over it, the host answers429with aRetry-After.BOTHY_PEER_MAX_CONCURRENT(default 0, off) caps how many of those slots one caller may hold at once. Without it the cap is first-come-first-served: one client with parallel requests takes the whole GPU and everybody else is told the host is full. The refusal says which it is — "the host is full" is a reason to try elsewhere, "you already have your share" is a reason to wait.BOTHY_MAX_REQUEST_TIME(default 0, off, e.g.10m) stops a single request at a wall-clock limit, and it is the only lever that bounds how long one generation can occupy the GPU. The caller gets a504naming the limit; a reply already streaming is simply cut off, because the headers are long gone.BOTHY_MAX_BODY(default 32 MiB) refuses a request body larger than this with a413, before the engine is asked. Generous on purpose: a prompt carrying images is legitimately megabytes.BOTHY_PEER_QUOTA(default off) is a sustained budget, written ascount/period—200/1his 200 requests, and then wait for the window. This is what stops one person using your GPU all day: a rate limit only slows them down, because 30 a minute is still 43,200 a day.BOTHY_MAX_REQUESTS_PER_MINUTE(default 0, off) caps one peer's request rate, with a burst of the same size.
And the routes: a host proxies inference and nothing else. POST /api/pull,
DELETE /api/delete and the rest of the engine's control API answer 404 and
never reach the engine, because they are not inference and a stranger with a port
number should not be able to delete your models. -allow-routes opens specific
paths for an engine Bothy does not know; -allow-all-routes restores the old
proxy-everything behaviour for a network you control.
The host prints every one of these at startup, and they are all in
/bothy/healthz and /bothy/usage, so "what did I actually configure?" has an
answer that is not shell history.
A budget counts requests, not tokens, and that is a limitation rather than a preference. Tokens are known only after a response has been produced, so a token budget could only ever be enforced after the fact — and an engine that reports no usage, which is allowed, would evade it entirely. Requests are counted before the work starts, so a request budget always binds. The tokens are still in the usage report for whoever is judging by them.
Free slots are advertised in the registry as each host's free, which is how a
client routes to whichever host is least busy rather than to whichever
registered first. That figure is free peer slots, so a host whose only free slot
is the reserve looks full to everybody else — and a client needs to understand
none of it. A host that reported no number at all is not treated as full: it is
unknown, and unknown sorts after known rather than being excluded.
Stopping the host works, but it also drops it out of the registry and leaves connected clients with a connection error rather than an answer. Set an admin key and you can pause instead:
python -m bothy share -admin-key a-secret
curl -X POST http://localhost:7777/bothy/sharing \
-H 'X-Bothy-Key: a-secret' -d '{"paused": true}'Peers get a 503 that says what happened, the host stops announcing itself so
clients route elsewhere, and your own engine is untouched throughout. One boolean
in one POST means a sharing schedule is two cron entries — pause at 9am, resume at
6pm. Without -admin-key there is no control endpoint at all, and it is
deliberately not a share key: peers hold those, and a peer who can stop your host
is worse than no control.
Three honest caveats:
- The counts come from the engine's own report, and an engine that reports
nothing is recorded as
unmetered_responsesrather than guessed at. A host cannot produce a token count it was never given — which is exactly why the meter is trustworthy only as far as the host is. - Streamed replies are counted, and that takes work. An OpenAI-compatible
engine reports usage on a stream only when the request asks for it, so the host
adds
stream_options.include_usageon the way to the engine — the one request body Bothy ever rewrites, and only on the two streaming OpenAI routes. A caller that has already expressed a preference is never overridden, andBOTHY_STREAM_USAGE=falseturns the whole thing off. Two cases still land as unmetered: a request body over 1 MiB, which is forwarded rather than read, and an engine that ignores the ask. - Limits apply to the inference path only.
/bothy/modelsand/bothy/usageare metadata, cost no GPU time, and are deliberately not throttled. - The client's local endpoint accepts any API key, because it only listens on loopback. The share key is what reaches the host.
Billing would be built on exactly this counter. You cannot charge for something you do not count, which is why the meter came before any payment discussion.
Discovery means one thing: how does the client learn which host to talk to? Everything else is detail.
It gets confusing because it is three problems wearing one name:
- Address exchange — getting
host:portto the client. Easy. It's a phone book: a file, a database, an API. - Reachability — can the client actually connect? Hard, and not a data problem at all. Most volunteer GPUs sit behind a home router with no open port and a changing IP, so the phone book can list an address nobody can dial. A registry does not fix this. Nothing in this repo fixes it yet.
- Trust — finding an address is not the same as being allowed to use it, or knowing it is the machine you think it is. Hence share keys and digests.
The registry in this repo solves (1) only. It is a bulletin board, not an authority: it cannot check that an address is up, and it cannot check that a digest is honest.
Open it in a browser and you get the bulletin board itself — a read-only page at
/ listing what is live, who is serving it and how long ago they checked in:
bothy registry
3 live entries · ttl 1m0s · registration is open
MODEL HOST ADDRESS FREE DIGEST LAST SEEN
llama3.1:8b box box.example:7777 3 sha256:11111111… 4s ago
qwen2.5:7b hydra hydra.lan:7777 0 sha256:22222222… 19s ago
It is a view of /models, not a second contract: no state, no query, no
per-client logging. Anything that needs to depend on the data reads the JSON.
The ladder, cheapest first — Bothy sits at rung 2, and rung 0 is fine for a long time:
| Approach | Cost | |
|---|---|---|
| 0 | Paste the address and key to each other | nothing |
| 1 | A static JSON list at a URL, edited by hand | nothing |
| 2 | A registry with heartbeats ← this repo | a small service |
| 3 | DHT or gossip, signed lists | a lot |
Registration doubles as the heartbeat: a host re-POSTs every
BOTHY_HEARTBEAT (default 20s) and entries expire after BOTHY_REGISTRY_TTL
(default 60s), so machines that went to sleep fall out of the list instead of
being handed to clients forever. BOTHY_REGISTRY_TOKEN gates registration;
without it, anyone reachable can publish entries.
The registry is the one piece that has to be reachable by everybody: hosts
announce to it, clients ask it who has a model. It is also the cheapest piece to
host, because it holds nothing on disk — registrations live in memory and expire
after BOTHY_REGISTRY_TTL, so a restart costs one heartbeat (20 seconds by
default) and it is as good as new. Anywhere that runs a small container or
process will do: a single Fly.io machine, Render's free web service, Cloud Run
with --max-instances=1, an always-free VM, or the machine next to your GPU.
Three things decide whether it works:
- One instance, never a fleet. The store is in memory, so two copies would each hold whichever hosts registered against them and a client's lookup would miss the rest. Do not put it behind a round-robin load balancer, and do not let it autoscale.
- A token, if it is on the internet. Registration is open unless
BOTHY_REGISTRY_TOKENis set, and an open registry lets anyone publish entries that clients will then dial. Set the token and give it to your hosts with-register-token. $PORTis honoured. A platform that tells a process which port its traffic arrives on gets what it asked for: an explicitBOTHY_LISTENbeats$PORT, and$PORTbeats the command's own default.
docker run -p 8080:8080 -e BOTHY_REGISTRY_TOKEN=a-secret bothy discoveryThe harder half is the hosts, not the registry. A GPU behind a home router has to
be dialled by peers, so BOTHY_PUBLIC_ADDRESS must be an address that resolves
for them — a tunnel (Cloudflare Tunnel, Tailscale Funnel) or a port forward. An
entry in the registry is only a phone number; Bothy cannot make the phone ring.
# nothing to build: run it from the checkout
python -m bothy run -share-key secret -discovery-url http://localhost:8080
# or the halves separately
python -m bothy discovery -listen :8080
python -m bothy share -engine-url http://localhost:11434 -share-key secret \
-discovery-url http://localhost:8080 -address mybox.example:7777
python -m bothy connect -discovery-url http://localhost:8080 -model llama3.1:8b -share-key secretEvery flag has a BOTHY_* environment default, which is how the containers are
configured. Run any command with -h. The important ones:
| Variable | Role | Meaning |
|---|---|---|
BOTHY_CLIENT_LISTEN |
run | where to borrow: 127.0.0.1:11223 |
BOTHY_HOST_LISTEN |
run | where to share: :7777 |
BOTHY_SERVE |
run | share a local engine when one is found (default true) |
BOTHY_LISTEN |
all | address to listen on; on run it means the host's |
BOTHY_ENGINE_URL |
host | your engine's base URL |
BOTHY_ENGINE_KIND |
host | auto, ollama, openai, mock, static |
BOTHY_PUBLIC_ADDRESS |
host | the address to advertise — what peers dial, not what you listen on |
BOTHY_SHARE_KEY |
both | the group secret: your host requires it, your client presents it |
BOTHY_DISCOVERY_URL |
both | registry to announce to / look up in |
BOTHY_SHARE_KEYS |
host | per-peer keys, alice:key,bob:key; wins over BOTHY_SHARE_KEY |
BOTHY_MAX_CONCURRENT |
host | requests served at once (default 4; 0 = no cap) |
BOTHY_OWNER_RESERVE |
host | of that cap, slots peers may not use (default 1) |
BOTHY_PEER_MAX_CONCURRENT |
host | slots one caller may hold at once (default 0 = no separate cap) |
BOTHY_MAX_REQUEST_TIME |
host | wall-clock limit for one request, e.g. 10m (default 0 = no limit) |
BOTHY_MAX_BODY |
host | largest request body in bytes (default 32 MiB; 0 = no cap) |
BOTHY_ALLOW_ROUTES |
host | extra engine paths to proxy, e.g. POST /api/pull |
BOTHY_ALLOW_ALL_ROUTES |
host | proxy every engine path, control routes included (default false) |
BOTHY_PEER_QUOTA |
host | per-peer request budget as count/period, e.g. 200/1h |
BOTHY_MAX_REQUESTS_PER_MINUTE |
host | per-peer request rate (default 0 = no cap) |
BOTHY_ADMIN_KEY |
host | key for POST /bothy/sharing; unset means no control surface |
BOTHY_MODELS_DIR |
host | Ollama models dir, for real weights digests |
BOTHY_MODEL / BOTHY_EXPECTED_DIGEST |
client | what to use, and what to insist on |
BOTHY_HOST |
client | skip discovery and connect straight to an address |
python -m bothy run reads BOTHY_HOST_LISTEN for the sharing half and
BOTHY_CLIENT_LISTEN for the borrowing one, because one process running both
cannot use one BOTHY_LISTEN for two ports. A plain BOTHY_LISTEN still means
the host's, as it does for python -m bothy share.
A flag is for the thing you are trying once. A config file is for the machine's standing policy — and on a host that shares a GPU for months, the limits are exactly the part worth writing down and reading back later.
python -m bothy config path # which file is in use, and whether it exists
python -m bothy config init # write a commented one to uncomment from
python -m bothy config init -force
python -m bothy config path -config ./bothy.conf # or point somewhere else# %AppData%\bothy\config — or ~/.config/bothy/config, Application Support on macOS
BOTHY_ENGINE_URL = http://127.0.0.1:11434
BOTHY_LISTEN = 127.0.0.1:7777
BOTHY_PEER_MAX_CONCURRENT = 1 # one caller cannot take the whole GPU
BOTHY_PEER_QUOTA = 200/1h # and cannot use it all day
BOTHY_MAX_REQUEST_TIME = 10m # one generation, boundedKeys are the environment variable names, so a setting has exactly one name
everywhere it appears — in the file, the environment, -h, and this table.
Precedence is flag, environment, file, built-in default, which is what lets a
file hold the machine's policy while a container or a one-off command overrides
part of it. -config <path> (or BOTHY_CONFIG) points at a different file.
A file that exists is held to a standard, because the alternative is a typo that silently configures nothing. An unparseable line is an error naming the line number, and an unknown setting is an error naming it and suggesting what you meant:
bothy: ~/.config/bothy/config line 3: unknown setting "BOTHY_MAX_CONCURENT",
did you mean BOTHY_MAX_CONCURRENT?
On Unix the file is created 0600, since it may hold a share key.
Bothy is HTTP/JSON, and PROTOCOL.md is the contract. The
implementation in this repository is one implementation, not the definition —
nothing outside this file depends on it, and any language that speaks the protocol
interoperates with it.
examples/python/bothy_client.py is a
working client that speaks it — standard library only, no pip install:
python3 examples/python/bothy_client.py registry --registry http://localhost:8080
python3 examples/python/bothy_client.py models --host box.example:7777 --key secret
python3 examples/python/bothy_client.py chat --host box.example:7777 --key secret \
--model llama3.1:8b --prompt "who are you?" --streamProvides:
- A remote GPU that looks local to any OpenAI- or Ollama-compatible client.
- Model identity you can check before sending work.
- A share key, so your GPU isn't open to whoever finds the port.
Does not provide:
- Anonymity. Both sides see each other's IP.
- Prompt privacy from the host. They run your prompt and can read it.
- Proof the claimed model was served. See the digest caveat above.
- A trustworthy meter on someone else's machine. The counts come from the host. Harmless while the network is free; the central problem the moment anyone pays.
- Reachability. A host without an open port or a tunnel cannot be reached, no matter what the registry says.
- Model parallelism. Each host holds whole models; Bothy routes, it does not split one model across machines.
One host, one client, one model — working end to end.(done)Digest matching enforced on both sides, with the mismatch path tested.(done)Metering, with concurrency caps and per-peer rate limits.(done)- Multiple models per host, surfaced through
/v1/models. Multiple hosts per client, routed by model and then by load.(done — a refusal moves the request to the next host, and hosts that reported free slots are asked first)- Reachability: a relay or tunnel so home GPUs can be used without port forwarding. This is the hard half of discovery.
- Open question: paid hosts. The meter is the foundation, but payment needs the honesty problem solved first — a paying host has a motive to lie about which model ran and how many tokens it produced, and the meter is on their machine. Any design also has to decide whether Bothy takes a cut. It should not: a commission is precisely what turns open-source software into a regulated money transmitter.
- Later: reputation, Tor transport, incentives.
One idea is parked rather than disproved. Farming problems rather than prompts
— fanning a checkable task out across borrowed GPUs — was built here once and then
retired, because it never shipped and a build tag has no Python equivalent. The
part worth keeping is the filter it turned on: only farm work whose answer can be
checked more cheaply, and more trustworthily, than it can be produced. Its design
note is docs/swarm.md in git history.
- Whether one request should be spread across several hosts, and how a burst arriving at once avoids filling one host while the fleet has room.
- Transport between host and client: plain HTTP on a private network today; TLS with a pinned certificate before this is exposed to the open internet.
- Where the share key lives and how it rotates.
- What
freereally measures — it is free peer slots, so it says nothing about speed, and it is the host's own claim. - Whether the registry should sign entries, so clients can detect tampering.
No anonymity layer, no payments or incentives, no model parallelism, no public directory of strangers' GPUs.
git clone https://github.com/hibbault/bothy
cd bothy
make check # byte-compile every module, then the tests
make e2e # the whole stack over real HTTP: four processes, no GPU, no Docker
make devnet # the same stack in containersPython 3.9 or newer. There are no dependencies to install, so there is no virtualenv to make and no lockfile to update. Docker is optional, and only for the devnet. See CONTRIBUTING.md for the layout, the ground rules, and how to extend it.
Apache-2.0. Use it, modify it, run it commercially, ship it — the patent grant is explicit, and attribution is the only condition. Contributions come in under the same license, with no CLA.