Self-host the planner
Tealhand Planner turns one sentence (“shut down the studio PC when blender finishes”) into at most three proposed actions. It holds no key, stores nothing and logs no content: only status codes, durations and step counts. Its answer is only a proposal. The phone checks it again and shows a card for every step.
1. Set up Ollama
Section titled “1. Set up Ollama”- Run Ollama on the same machine, listening on loopback only. The planner talks to it at
http://127.0.0.1:11434by default. - Turn Ollama’s request logging off, and set
OLLAMA_NO_CLOUD=1for Ollama. - Pull a model that you want to use. Models whose name contains
:cloudor-cloudare refused, because Ollama would send the prompt to a third party. That is only a name check: Ollama itself must refuse cloud routes, which is whatOLLAMA_NO_CLOUD=1is for.
2. Start the planner
Section titled “2. Start the planner”tealhand-planner --bind 127.0.0.1:8787 --model <ollama model>| Option | Environment variable | Default | Meaning |
|---|---|---|---|
--bind |
TEALHAND_PLANNER_BIND |
none, required | Address to listen on. 0.0.0.0 and :: are refused |
--model |
TEALHAND_PLANNER_MODEL |
none, required | Ollama model name |
--ollama-url |
TEALHAND_PLANNER_OLLAMA_URL |
http://127.0.0.1:11434 |
Ollama’s address: plain http on a loopback IP, no host names, no user or password |
--timeout-secs |
30 | Seconds for one request, the model call included | |
--max-concurrent |
2 | Model calls at once; more get 503 | |
--rate-burst |
10 | Requests one client address may send in a burst | |
--rate-per-minute |
30 | Requests per minute one client address may keep up |
The last four must be 1 or more. --help and --version work as usual.
3. Reach it from the phone
Section titled “3. Reach it from the phone”The planner speaks plain HTTP and does no TLS itself.
- On your local network: bind it to the machine’s private address, for example
--bind 192.168.1.20:8787, and give the phonehttp://192.168.1.20:8787. The phone accepts plainhttponly for a loopback or private IP address. - Anywhere else: put it behind a TLS reverse proxy that enforces header-read and body timeouts, and give the
phone the
https://address. Behind a proxy the rate limit sees only the proxy’s address, so all users share one budget.
The phone posts to <planner address>/v1/plan.
What the planner gets
Section titled “What the planner gets”Only your sentence, the names of your computers (as D1, D2 and so on) and the language. Never status, tasks,
files or keys. The phone sends only what you typed or said, never text from a web page, a file or a computer.
The API
Section titled “The API”POST /v1/plan with a JSON body:
{"sentence": "shut down the studio PC when blender finishes", "devices": [{"ref": "D1", "name": "Studio PC"}], "lang": "en"}| Field | Rule |
|---|---|
sentence |
1 to 500 characters, not only spaces |
devices |
1 to 16 entries |
devices[].ref |
D and one or two digits, each used once |
devices[].name |
1 to 64 characters, no control or invisible characters, no spaces around it |
lang |
Optional, default en; at most 8 letters, digits or - |
The whole body is at most 8 KiB, and unknown fields are refused. A plan answer:
{"steps": [{"device": "D1", "action": {"action": "rule.add", "params": {"trigger": {"process_ends": {"name": "blender.exe"}}, "then": "shutdown"}}}]}or, when nothing fits (also what a refused or malformed model answer becomes):
{"none": "no matching action"}A plan has 1 to 3 steps. Each step names a device ref from the request and one action the chat may propose:
device.status, device.tasks, rule.add, rule.list, power.sleep or power.shutdown. The model’s own text
is never passed on.
| Status | Body | Meaning |
|---|---|---|
| 200 | {"steps": [...]} or {"none": "no matching action"} |
A proposal, or nothing fits |
| 400 | {"error": "..."} |
The request was refused: body too large or unreadable, malformed request, sentence must be 1 to 500 characters, 1 to 16 devices are required, bad lang, bad or repeated device ref, bad device name |
| 429 | {"error": "slow down"} |
Too many requests from this client |
| 503 | {"error": "busy"} or {"error": "planner unavailable"} |
All model slots are in use, or Ollama could not be reached |
| 504 | {"error": "timed out"} |
The whole request took longer than --timeout-secs |
Try it:
curl -s http://127.0.0.1:8787/v1/plan -H 'content-type: application/json' \ -d '{"sentence": "is my PC busy?", "devices": [{"ref": "D1", "name": "Studio PC"}]}'How the model is asked
Section titled “How the model is asked”The planner sends Ollama’s chat API a fixed system prompt, your request as one JSON object (so quotes and new
lines in the sentence cannot break out of it), and a JSON Schema that allows only the plan shape and the chat
actions. Options: no streaming, thinking off, temperature 0, a 4096-token context, at most 512 tokens of answer,
and the model kept loaded for 5 minutes. The answer is then parsed and checked again in Rust; anything that does
not pass becomes {"none": "no matching action"}.
Rate limits
Section titled “Rate limits”Each client address (IPv6 per /64) gets a bucket of --rate-burst requests that refills at --rate-per-minute.
The planner remembers at most 10,000 client buckets in memory, and none of them survives a restart.