Skip to content

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.

  • Run Ollama on the same machine, listening on loopback only. The planner talks to it at http://127.0.0.1:11434 by default.
  • Turn Ollama’s request logging off, and set OLLAMA_NO_CLOUD=1 for Ollama.
  • Pull a model that you want to use. Models whose name contains :cloud or -cloud are 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 what OLLAMA_NO_CLOUD=1 is for.
Terminal window
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.

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 phone http://192.168.1.20:8787. The phone accepts plain http only 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.

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.

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:

Terminal window
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"}]}'

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"}.

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.