TypeSafe bracht Jev uit en mijn hele feed ging compleet uit z’n dak. Ze noemen het een System One-model: je geeft het een rommelige state plus een paar getypeerde vragen, en je krijgt een keuze, een score of een ja/nee terug met een echte kans erbij. Geen opstel, geen “Natuurlijk! Hier is de JSON waar je om vroeg:”, geen proza die je weer moet terugparsen naar dat ene woord dat je wilde hebben. Eer wie eer toekomt: dat is verdomme het juiste idee.
Toen las je de kleine lettertjes. Gehoste API. Early access. Wachtlijst. Geen gewichten. En juist wat je beoordeeld wilt hebben is per definitie het gevoelige spul, “deze agent staat op het punt de customers-tabel te droppen”, dus dat moet je netwerk uit en in de wachtrij van iemand anders gaan zitten voordat je je ja of nee krijgt. Mooi niet.
Dus deed ik wat ik al eerder deed met talkies voor spraak, flickies voor video en predictalot voor voorspellingen. Ik ben de open modellen die dit werk doen langsgelopen, heb de twee beste gehouden, Laya en Von, en die in één Docker-image achter één API gespijkerd. Dat is decidealot, de offline Jev: dezelfde vorm van request en response als System One, MCP op dezelfde poort, jouw hardware, geen cloudrekening, geen wachtlijst, en waar je naar vraagt verlaat je eigen bak nooit. Daarna heb ik decidealot in aigate geschoven, pal naast zijn broertjes, dus draai je die stack al, dan kost het je één env var.
Een Tekstgenerator om een Beslissing Vragen Is Godsgruwelijk Dom
Jev ging viraal omdat de status quo zo dom is als het achtereind van een varken:
- LLM’s als classifiers. Een model dat gebouwd is om tekst te genereren, genereert tekst, en die parse jij dan weer terug naar het ene woord dat je wilde. Je smeekt om “ALLEEN geldige JSON, geen uitleg” en krijgt het verpakt in markdown-fences met een behulpzaam notitietje over zijn redenering, plus die ene keer op de vijftig dat het model een vierde categorie verzint die je nooit hebt opgegeven. “Antwoord alleen met JSON” is geen contract, het is een gebed, en structured-output-modi verschuiven het parsen alleen naar de sampler van iemand anders. Je betaalt nog steeds voor token-voor-token decoding om een label te produceren.
- Zelfgerapporteerde zekerheid. Vraag een chatbot hoe zeker hij is en hij zegt “85%”, een getal dat hij met een stalen gezicht uit zijn reet heeft getrokken omdat er op die plek een getal mooi stond. Dat getal is nooit ook maar in de buurt van een softmax geweest. Je kunt er geen drempel op zetten, je kunt het niet kalibreren, en je kunt het niet in een audit log zetten en het later verdedigen.
- Latency verspild aan niks. Elke “Zeker! Op basis van de gegeven context” is kloktijd die tussen je event en je beslissing in zit.
- De open-weight modellen. De beste twee die dit werk lokaal doen zijn Laya van NandhaKishorM en Von van wfzyx, gewichten onder Apache 2.0. Prima. Alleen komt elk van de twee met een eigen server met eigen meningen over hoe een request eruitziet, een eigen installatie en een eigen Torch-runtime, en geen van beide slikt het officiële TypeSafe-request zoals het is. Wil je ze allebei, dan draai je twee servers, twee installaties en twee Torch-runtimes, en schrijf je de lijm zelf.
Ik wilde één bak die ik één keer start en waar ik alles op richt. Het gehoste contract, zodat code die daartegen geschreven is geen tweede API hoeft te leren. Beide lokale modellen erachter. MCP voor de agents. En niet met een geladen model en een complete Torch-runtime in het RAM blijven zitten terwijl er geen hond iets vraagt.
Drie Vraagtypes, Eén Request
Het contract is klein. Je stuurt een model, een state en een map met benoemde questions. De state is wat je beoordeeld wilt hebben: een string, een JSON-object of een array. De namen van de vragen verzin je zelf en die komen terug als de keys onder answers. Elke vraag is van een van drie types:
choicekiest één label uit de keys vancriteria. Die keys zijn de enige antwoorden die eruit kunnen komen. Een vierde vakje verzinnen kan niet, want er wordt geen godverdomde letter geschreven. Je krijgt dechoice, eenconfidenceen een kans voor elk label.scoreneemt een geordende array met criteria waarin de positie de score is, beginnend bij 0. Je krijgt een verwachte score terug, dus 1.9 op een schaal met drie niveaus is een echt antwoord dat “blocking, met een vleugje soon” betekent, plus kansen per niveau en eenlegenddie de posities terugkoppelt naar je eigen formulering.noulis ja of nee. Eén veld,noul, de kans dat de bewering waar is. Geen apart confidence-veld, want dat getal ís de confidence al.
Stop ze alle drie in één request en ze worden tegen dezelfde state beantwoord:
curl --fail http://127.0.0.1:8080/v1/systemone \
--header 'Content-Type: application/json' \
--data '{
"model": "laya",
"state": "You billed me twice for March. Refund the duplicate today or I am cancelling.",
"questions": {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"billing": "Invoices, payments, refunds.",
"technical": "Bugs, outages, errors.",
"other": "Everything else."
}
},
"urgency": {
"type": "score",
"criteria": ["not urgent", "soon", "blocking"]
},
"churn_risk": {
"type": "noul",
"instructions": "Does the customer threaten to leave?"
}
}
}'Dat is geen verzonnen voorbeeld. Dit is wat Laya echt terugstuurde, draaiend op de CUDA-image achter mijn eigen aigate-bak:
{
"model": "laya",
"answers": {
"department": {
"type": "choice",
"choice": "billing",
"confidence": 0.8055,
"probabilities": { "billing": 0.9543, "technical": 0.0317, "other": 0.014 }
},
"urgency": {
"type": "score",
"score": 1.8986,
"confidence": 0.7053,
"legend": { "0": "not urgent", "1": "soon", "2": "blocking" },
"probabilities": { "0": 0.0222, "1": 0.0571, "2": 0.9208 }
},
"churn_risk": {
"type": "noul",
"noul": 0.2673
}
},
"usage": { "input_tokens": 156, "output_tokens": 0 }
}Kijk naar output_tokens. Nul. Laya heeft geen enkel token geschreven, dus er kan ook niks terugkomen als “Natuurlijk! Hier is”. Hetzelfde request naar Von meldt er drie, en dat is nog steeds mijlenver van een alinea. Hoe dan ook ligt de vorm van het antwoord vast in het schema, niet in de vraag of het model vandaag toevallig een reet gaf om je instructies.
Kijk nu naar churn_risk. De klant schreef letterlijk “or I am cancelling” en Laya schatte de dreiging op 0.27. Ik stuurde Von hetzelfde request als second opinion en die kwam op 0.23. Billing en blocking, bij allebei schot in de roos. De opzegdreiging, daar haalden ze allebei hun schouders over op. Dat is niet decidealot die iets verknoeit, dat is wat de modellen antwoordden, en het is precies de reden dat de volgende alinea bestaat. Test je vragen op je eigen cases voordat je er een drempel aan hangt. Een vraag herformuleren is goedkoop. Er in productie achter komen niet.
decidealot geeft je de getallen en daar houdt het op. Zelf ingrijpen doet het niet. Jouw code is de baas over de drempel: toestaan als allow boven 0.95 uitkomt, al het andere in de wachtrij voor een mens, en het complete antwoord naast het actierecord opslaan. Dezelfde houding als predictalot, alleen één laag verder. Getallen erin, en de beslissing wat je ermee doet blijft van jou.
Twee Modellen, Tien Selectors, Eén in het Geheugen
Elk request noemt een model. Er is geen default, dus je beslissingen wisselen niet stilletjes van brein omdat een of andere klootzak een env var heeft aangepast. GET /v1/models geeft de catalogus terug, en dit zijn ze, alle tien:
laya,laya-auto,laya-latest: Laya met automatische checkpoint-routing. Laya kijkt naar het schrift en de taal van de state en kiest zelf het Engelse of het meertalige checkpoint.laya-english: het Engelse checkpoint, voor Engels in Latijns schrift.laya-multilingual: het meertalige checkpoint, voor al het andere, inclusief korte tekst in Latijns schrift die niet duidelijk Engels is.laya-typed-decisions: het checkpoint dat is afgestemd op herhaalde gestructureerde workflow-calls, zoals policy, routing, triage en goedkeuringen. Test het op je eigen cases voordat je erop vertrouwt.von,von-latest,von-1.1,von-1.1.0: Von, een onafhankelijk model dat alleen Engels doet, voor korte, goed geformuleerde beslissingen. Nuttig op zichzelf, en nuttig als second opinion voordat je een workflow op Laya standaardiseert.
Laya is één modelfamilie met drie checkpoints. Von is een ander model van andere mensen. Allebei nemen ze hetzelfde request aan en geven ze dezelfde antwoordtypes terug, en dat is precies waarom ze achter één contract zitten: verander de model-string en vergelijk.
Unload Betekent: het Proces Gaat Dood
Dit is het stuk waar het mij echt om gaat. Er zitten drie Python-virtualenvs in de image. /opt/app-venv is de gateway: FastAPI, httpx, de MCP SDK, pydantic, uvicorn. Geen Torch, nergens in de broncode van de gateway ook maar één import ervan. /opt/laya-venv en /opt/von-venv bevatten elk de stack van één model. Op dit moment pinnen ze toevallig allebei dezelfde torch en transformers, dus dit is geen workaround voor een ruzie die al gaande is. Het betekent dat een upgrade van Laya nooit in de omgeving van Von kan grijpen, en dat er in het proces dat je HTTP-requests beantwoordt nooit een model zit.
Elk model draait als childproces van de gateway, gestart door een supervisor vanuit een vast commando zonder shell, luisterend op een vaste loopback-poort. Er is er steeds maar één resident. Vraag je om Von terwijl Laya geladen is, dan wacht de supervisor tot elk lopend Laya-request klaar is, schiet Laya af, start Von en pollt elke kwart seconde de /health van Von tot Von antwoordt. Wisselen tussen de Laya-selectors blijft binnen het Laya-proces, want dat zijn checkpoints van hetzelfde ding. Uitgekleed ziet de sluis er zo uit:
async with self._provider_switch_condition:
spec = self._require_spec(provider_name)
while self._has_active_other_provider(provider_name):
await self._provider_switch_condition.wait()
await self._unload_other_idle_providers_locked(provider_name)
await self._start_provider_locked(spec)
self._active_requests[provider_name] += 1
try:
yield
finally:
async with self._provider_switch_condition:
self._active_requests[provider_name] -= 1
self._last_used_at[provider_name] = asyncio.get_running_loop().time()
self._provider_switch_condition.notify_all()Waarom een heel proces in plaats van del model en een schietgebedje richting de garbage collector? Omdat je het geheugen daarmee niet terugkrijgt. De caching allocator van PyTorch klampt zich vast aan wat hij heeft gepakt, en de CUDA-context blijft staan zolang het proces leeft. Het proces afschieten is de enige unload die echt een unload is, dus dat is wat unload doet: terminate, tien seconden respijt, en daarna kill als het proces zich aanstelt als een verwend kutkind. Gewichten, Torch-allocaties, workerthreads en de CUDA-context gaan er allemaal mee het graf in. Het volgende request start het weer op.
Die start is niet gratis, en ik ga niet doen alsof. Op mijn GPU-bak, via aigate, duurde het eerste Laya-request na een koude start ongeveer 61 seconden, omdat het proces moest opstarten en het model moest laden. Het volgende identieke request kwam in 55 milliseconden end-to-end terug, netwerk inbegrepen, met byte voor byte hetzelfde antwoord. Overstappen naar Von duurde ongeveer twee minuten, want Laya moest eerst plat en Von moest nog opstarten. Een warme Von antwoordde in ongeveer 115 milliseconden. De idle timeout is dus een echte afweging: zet hem lang genoeg dat je verkeer niet steeds de koude start betaalt, en kort genoeg dat de GPU niet de oppas speelt voor een model dat niemand gebruikt.
Twee dingen zetten dat in gang. Een idle reaper unloadt een provider die DECIDEALOT_PROVIDER_IDLE_UNLOAD_SECONDS lang ongebruikt heeft gestaan, standaard 600, en kijkt telkens na een tiende van dat venster, begrensd tussen 10 milliseconden en 30 seconden. Zet die waarde op 0 en alleen de timer gaat uit. En POST /v1/models/unload doet het op commando. Die route is alles of niets: zit een provider midden in een request, dan krijg je een 409 PROVIDER_BUSY en wordt er niks vrijgegeven. Er wordt nooit een model onder de kont van een caller vandaan getrokken.
Eén Keer Downloaden, Daarna Gaat de Stekker Eruit
Je mount één hostmap op /models, en decidealot beheert daarbinnen /models/laya en /models/von. Bij de eerste start draait de supervisor voor elk model een prepare-stap in de eigen venv van dat model, en die haalt de Hugging Face-snapshot binnen, convaiinnovations/laya en wfzyx/von, elk vastgepind op een exacte commit-revisie. Daarna controleert de supervisor of elk bestand dat de runtime nodig heeft er ook echt staat: vier bestanden in elk van de drie checkpointmappen van Laya, zes voor Von. Niets daarvan importeert Torch. /health antwoordt 503 tot beide bundels klaar zijn, wat de eerste keer ongeveer 5.3 GB en een paar minuten is. Daarna staan de bestanden er al en wordt er niks gedownload.
Dan, voordat een model laadt, zet decidealot HF_HUB_OFFLINE=1 en TRANSFORMERS_OFFLINE=1. Is de bundel eenmaal geverifieerd, dan kan geen enkel model halverwege een run nog terugdwalen naar de Hub om een of andere shit op te halen die je niet hebt gepind.
Twee Upstream-servers Eén Contract Laten Spreken
Volgens het officiële schema is instructions optioneel en mag het een geneste JSON-waarde zijn, en dat geldt ook voor de waarden van de criteria. Laya wil op elke vraag een instructions-veld. Von wil dat dat veld een string is. Stuur een van de twee een request dat de officiële API zonder morren slikt en je wordt geweigerd om een vormverschil waar niemand om gevraagd heeft.
Dus valideert decidealot je request eerst tegen de officiële requestmodellen en bouwt daaruit de native body voor elk model: een ontbrekende instructions wordt een lege string, en geneste waarden worden als compacte JSON-tekst weergegeven. Je choice-labels, je scorevolgorde en je state gaan er onaangeroerd doorheen. Op de terugweg wordt het antwoord van de provider gevalideerd tegen het officiële responseschema. Rommel die alleen de provider kent, zoals het routingblok van Laya, wordt weggegooid, en model wordt gezet op de publieke naam van wat er daadwerkelijk antwoordde. Levert een provider iets terug dat niet in het schema past, dan krijg je een nette 503 in plaats van troep die zich als beslissing vermomt. Weigert een provider een request met een kaal bericht, dan wordt dat herschreven naar de officiële validatie-envelop {"detail": [...]}, zodat je foutafhandeling maar één vorm ooit te zien krijgt.
En dan is er de server van Von, die zijn backend vindt via een processbrede singleton met een publieke constructor die je op geen enkele manier kunt vertellen waar het checkpoint staat. Dus bouwt decidealot de engine zelf en ramt hem in het slot waar de server uit leest:
engine = engine_type(
backend_name=os.environ.get(_von_backend_env, _default_von_backend),
device=os.environ.get(_von_device_env),
)
engine.backend = option_marker_backend_type(checkpoint_dir=str(model_dir), device=engine.device)
# Von's server resolves its backend from this singleton, whose public constructor
# has no checkpoint-directory argument.
with engine_type._lock:
engine_type._instance = engineJa, dat is graaien in een private singleton. Het is zo lelijk als de nacht, en precies zo lelijk als nodig is om Von naar een map te laten wijzen die jij beheert.
MCP op Dezelfde Poort
Dezelfde container serveert MCP Streamable HTTP op /mcp, met drie tools: system_one, list_models en unload_models. Die lopen via dezelfde beslisservice als de REST-routes, dus dezelfde validatie, dezelfde supervisor, dezelfde bodylimiet en hetzelfde bearer token. system_one neemt precies de model, state en questions die je zou POSTen, en geeft hetzelfde gestructureerde resultaat terug, zodat een agent de kansen kan lezen voordat hij beslist wat hij daarna doet. Een validatiefout komt terug als isError: true met de TypeSafe-detail-body in de tekst, zodat de agent ziet wat hij verkloot heeft in plaats van een kale “error”.
Geen enkele tool neemt een URL, een bestandspad, een executable, een modelmap of een runtime-optie aan. De modelrouter koppelt elke alias aan een vast loopback-endpoint, en een caller mag nooit zelf een netwerkdoel kiezen. Het meeste wat een agent hier kan, is een naam uit de catalogus kiezen.
decidealot achter een reverse proxy of een tunnel zetten betekent niet dat je de bescherming tegen DNS-rebinding uitzet. Je zet de exacte publieke Host op de allowlist in DECIDEALOT_MCP_ALLOWED_HOSTS en, voor browserclients, de exacte origin in DECIDEALOT_MCP_ALLOWED_ORIGINS. De checks lopen in een vaste volgorde: een ontbrekende of foute bearer krijgt eerst 401, dan krijgt een onbekende host 421, dan krijgt een onbekende browser-origin 403. Geen wildcards op een bak die aan het internet hangt. Zet DECIDEALOT_API_KEY op een echt geheim voordat de container ook maar in de buurt van een publiek adres komt.
De Saaie Shit Waardoor het Ding Gerust Aan Kan Blijven
- Een container die weinig mag. De gedocumenteerde run is een read-only root-filesystem,
--cap-drop ALL,no-new-privileges,noexec-tmpfs voor/tmpen/var/run, een pids-limiet, een geheugenplafond, en een poort die alleen op loopback gepubliceerd wordt. - Jouw UID, niet die van de image. De container draait als welke
--userje ook meegeeft, dus de modelmap die je net metmkdirhebt aangemaakt is schrijfbaar zonderchown-dansje. De image valt alleen terug op non-root1000:1000als je niks opgeeft. - Eén exec-gat voor CUDA, en maar één. Triton compileert tijdens runtime kleine CUDA-helpers en moet die ergens vandaan kunnen laden, dus de CUDA-run voegt één enkele
exec-tmpfs toe op/var/cache. Al het andere blijft read-only ennoexec. - Bearer-auth die geen timing lekt. Optioneel, uit tenzij
DECIDEALOT_API_KEYgezet is, vergeleken methmac.compare_digestop geëncodeerde bytes./healthblijft open voor je liveness probe. - Een bodyplafond. Standaard 1 MiB via
DECIDEALOT_MAX_REQUEST_BYTES, en alles wat groter wordt opgegeven krijgt een413voordat een model het ooit te zien krijgt. - Request-ID’s waar je op kunt greppen. Stuur een UUID of ULID mee in
X-Request-Iden die volgt het request door de logs. Stuur je rommel of niks, dan maakt decidealot zelf een UUID aan. Hoe dan ook komt het ID terug op de response. - Een supply-chain-slagboom waar de modellen te jong voor waren. De dependencies van de gateway zitten achter een uv
exclude-newer-leeftijdsgrens, en de modelstacks installeren met--require-hashesvanuit hash-gelockte bestanden. Beide modelpakketten zijn jonger dan de grens toestaat. De vastgepinde release van Laya staat niet eens op PyPI, dus die wordt geïnstalleerd vanuit de tarball van de upstream-commit, met de SHA-256 in de lock. Allebei hebben ze een uitgeschreven, door de eigenaar goedgekeurde uitzondering. De slagboom deed zijn werk en ik heb het toestemmingsbriefje zelf ondertekend. - Tests met een ondergrens. Branch coverage met een hard minimum van 90%, plus echte HTTP-runs tegen de gedownloade CPU- en CUDA-gewichten.
Twee images. De CPU-image is ongeveer een halve gig gecomprimeerd op Docker Hub en is de juiste default. De CUDA-image is ongeveer 9 GB, gebouwd op CUDA 12.6, alleen amd64, en heeft de NVIDIA Container Toolkit en --gpus all nodig. Grijp er alleen naar als snelheid en modelgeheugen de GPU-setup echt rechtvaardigen.
Draaien Die Hap
model_directory="$HOME/.local/share/decidealot/models"
mkdir --parents "$model_directory"
docker run --detach --name decidealot --init --restart unless-stopped \
--user "$(id -u):$(id -g)" \
--read-only --cap-drop ALL --security-opt no-new-privileges:true \
--pids-limit 512 --memory 8g --cpus 4 \
--tmpfs /tmp:rw,noexec,nosuid,size=128m \
--tmpfs /var/run:rw,noexec,nosuid,size=8m \
--mount type=bind,source="$model_directory",target=/models \
--publish 127.0.0.1:8080:8080 \
psyb0t/decidealot:latest
curl --fail http://127.0.0.1:8080/healthWacht tot /health groen wordt en stuur dan het request van hierboven. Voor de GPU voeg je --gpus all en de /var/cache-tmpfs toe, en gebruik je psyb0t/decidealot:latest-cuda. Het deployment-document in de repo heeft het volledige CUDA-recept.
Draai je aigate al, sla dan al die shit over. Zet DECIDEALOT=1 voor de CPU-service op /decidealot/ of DECIDEALOT_CUDA=1 voor de GPU-variant op /decidealot-cuda/, met MCP onder allebei, en de tools schuiven ook aan bij de geaggregeerde /mcp/ van aigate, naast al het andere waar je agents al mee praten. De twee kunnen naast elkaar draaien en één modelmap delen, dus die 5.3 GB komt maar één keer op schijf.
aigate houdt het geheugen ook voor je in de gaten. Voordat een ander lokaal model (Ollama, sd.cpp, talkies, vLLM, llama.cpp) op dezelfde hardware draait, zegt de resource manager van aigate tegen decidealot dat die moet unloaden. Beslissingen die via de MCP van aigate binnenkomen, nemen dezelfde hardwarelock als al het andere, en POST /v1/unload/cuda of /v1/unload/cpu ruimt decidealot mee op met de rest van de stack. Is er nog een beslissing onderweg, dan komt er 409 terug en blijft het model staan tot de idle timer afloopt, zodat er niets onder de kont van een caller vandaan wordt getrokken. Het ene gat: een request dat rechtstreeks naar /decidealot/ gaat, schopt niemand anders eruit, dus op dat pad sta je zelf nog met de GPU te jongleren.
In Je Agent Installeren
Een agent die op het punt staat op een kans te handelen, moet op zijn minst weten wat die kans betekent. De skill vertelt de agent hoe hij de container deployt, Laya of Von kiest, een getypeerde beslissing stuurt, de kansen leest zonder ze als toestemming op te vatten, en MCP rechtstreeks gebruikt. Alles onder .agents/ staat in één marketplace, dus het zijn twee commando’s:
claude plugin marketplace add psyb0t/agents
claude plugin install decidealot@psyb0tCodex gebruikt dezelfde marketplace met een ander werkwoord, codex plugin add decidealot@psyb0t, want codex plugin install bestaat niet. OpenClaw krijgt de skill, plus een optionele stdio-bridge voor clients die alleen met een lokale stdio-MCP-server kunnen praten, en die bridge stuurt door naar de container die je al draait:
openclaw skills install @psyb0t/decidealot
openclaw plugins install clawhub:@psyb0t/decidealotdecidealot Beslist. Jij Handelt.
Jev had het juiste idee op het verkeerde adres. decidealot is hetzelfde idee bij jou thuis: een gesloten set antwoorden, een echte kans op elk ervan, en nul kans dat je een opstel terugkrijgt. decidealot weet niet wat jouw drempel zou moeten zijn, en doet ook niet alsof. Jij kiest de grens op basis van wat het je kost als het fout zit, en het model heeft daar geen stem in.
Je vindt het op github.com/psyb0t/decidealot, of pull psyb0t/decidealot van Docker Hub. De code is WTFPL, dus doe er fucking mee wat je wilt. Laya, Von, PyTorch, Transformers en de gedownloade gewichten houden allemaal hun eigen licenties, dus lees die voordat je een model vastschroeft in iets wat je verkoopt. En nu ophouden met chatbots om een ja of nee vragen.