cactus-compute is inference, LoRA fine-tuning and export for Needle 3 in one Python package. Text goes in, a JSON tool call comes back; a byte-level grammar compiled from your schemas constrains every token, so the call is always well-formed. A sub-1 MB engine and the needle3.cact weights are fetched once from Hugging Face and cached; nothing is compiled locally. This page is the reference. The guides on designing tools, confidence, extraction, fine-tuning and devices are the narrative.
Install
pip install -U cactus-compute # runtime, text, speech and vision
pip install "cactus-compute[mic]" # adds microphone capture and resampling
pip install "cactus-compute[train]" # adds JAX and the training deps
pip install "cactus-compute[train,gpu]" # NVIDIA
pip install "cactus-compute[train,metal]" # Apple Siliconimport cactus is lightweight and never imports JAX; only cactus finetune and cactus build do. The engine downloads on the first Cactus(...). Anonymous usage counts (function name, package version, OS, a random install id; never prompts, outputs or data) can be switched off with CACTUS_TELEMETRY=0 or DO_NOT_TRACK=1, and CI environments are excluded automatically.
API
| Call | What it does |
|---|---|
cactus.Cactus(tools=None, system=None, weights=None, tool_index_path=None, buffer_size=65536, auto_date=True, generation=3) | An agent bound to one toolset. tools takes decorated functions, Pydantic models, raw JSON-schema dicts or a JSON string. system carries environment facts. weights loads a tuned .cact. tool_index_path is reserved for engine index archives. Cactus 4 runs Needle 3. |
agent.run(query, max_steps=8, max_new_tokens=512, strict=True) | The full loop: the model picks calls, Needle executes your functions and feeds results back, and the final response carries the executed tool results as results. |
agent.complete(text="", max_new_tokens=512, *, audio=None, language=None, keywords=None, word_timestamps=False) | One turn. You execute the call and feed the result back through the next complete(...). |
agent.embed(text) | A normalized vector for text, from the embedding head or the confidence probe pool. |
agent.reset() | Rewind the conversation, keep the tools loaded. |
cactus.tool | Decorator that turns a function into a tool schema. @cactus.tool(triggers=[...]) adds request regexes. |
cactus.Field(...) | Per-argument constraints, attached inline with typing.Annotated or passed as a default. |
cactus.extract(text, schema, system=None, max_new_tokens=512, weights=None, strict=True, generation=None) | One-shot extraction. A Pydantic instance if schema is a model, else a dict, or None if nothing matched. weights loads a tuned .cact; without it the base Needle 3 model runs. strict=True raises cactus.ExtractionValidationError instead of returning a date whose year contradicts the text or a value the engine flags as invented or negated. |
cactus.transcribe(audio, language=None, keywords=None, word_timestamps=False, weights=None) | Whistle, the speech model, on the same engine. A WAV path or 16 kHz mono float samples of any length, one pass up to 30 s and windowed beyond it. Returns text, language, ttft_ms and decode_tps, plus words with start, end and probability when timestamps are on. |
cactus.Whistle(weights=None, buffer_size=262144) | The same model as an object, for whistle.embed(audio), the encoder output at one row per 80 ms frame. Calls are serialized through the shared runtime. |
Speech
import cactus
print(cactus.transcribe("clip.wav")["text"])language is one of en de fr es it nl pl, or None to detect it. keywords takes a list or a newline-separated string and biases the search towards the names your users say. Silence and steady noise return an empty text and an empty language rather than an invented sentence; other sounds, music especially, can still come out as text, so put a voice activity detector or a wake word in front of an always-on microphone. A 16 kHz WAV or raw samples need nothing beyond the base install; other sample rates and the microphone need the [mic] extra.
The weights are a separate download from the engine, which is shared: whistle.cact comes from Cactus-Compute/whistle on first use, and CACTUS_WHISTLE_WEIGHTS points at a local copy. In the native engine the two models load side by side and cactus_complete takes a clip directly; from Python they are two objects over one library.
Tools, three ways
The decorator reads the signature for types, the docstring for the description, a Google-style Args: block for per-argument descriptions; a default makes an argument optional and a Literal becomes a fixed set the model cannot leave:
import cactus
from typing import Annotated, Literal
@cactus.tool
def set_thermostat(temperature: int, mode: Literal["heat", "cool", "auto"] = "auto"):
"""Set the thermostat.
Args:
temperature: target temperature in Celsius
mode: heating strategy to use
"""
return {"temperature": temperature, "mode": mode}
@cactus.tool(triggers=[r"\b(turn|switch|power|flip)\b.*\b(on|off)\b", r"\btoggle\b"])
def control_device(device: str, action: Literal["on", "off", "toggle"]):
"Switch or toggle any named smart-home device."
return {"device": device, "action": action}cactus.Field constrains values, and every constraint is compiled into the decode grammar:
@cactus.tool
def send_money(
amount: Annotated[float, cactus.Field(gt=0, le=10000, description="USD, up to 10,000")],
to: Annotated[str, cactus.Field(pattern=r"^@[a-z0-9_]+$", description="recipient handle")],
memo: Annotated[str, cactus.Field(max_length=80)] = "",
):
"Send money to a handle."
return {"sent": amount, "to": to}Field takes description, enum, const, ge/le/gt/lt, multiple_of, min_length/max_length, pattern, format, min_items/max_items and unique_items. A raw JSON schema is exactly what the engine consumes, and takes the same "triggers" list beside description:
tools = [{
"name": "set_lights",
"description": "Turn a room's lights on or off and set brightness",
"parameters": {
"type": "object",
"properties": {
"room": {"type": "string", "description": "which room to control"},
"on": {"type": "boolean"},
"brightness": {"type": "integer", "minimum": 0, "maximum": 100},
},
"required": ["room", "on"],
},
}]
agent = cactus.Cactus(tools=tools)OpenAI-style {"type": "function", "function": {...}} wrappers are accepted too. Java, JavaScript and camel-cased names are converted to collision-safe snake-case aliases for the model and restored exactly in the returned calls; descriptions, enum values and defaults are never rewritten.
The loop
import json
agent = cactus.Cactus(tools=[set_thermostat, control_device])
agent.run("make it 21 and cool the room")["results"]
r = agent.complete("toggle the garage door")
if r["type"] == "call":
out = control_device(**r["function_calls"][0]["arguments"])
r = agent.complete(json.dumps(out))A turn after a call is a tool result only when it parses as JSON; any other text is a new user turn. Later arguments may depend on earlier results (search_for_contact first, then send_instant_message with the returned id). A final "type": "respond" with empty function_calls ends the loop; the answer is the tool results, which run() collects as results. No free text is generated.
The response
{
"type": "call",
"success": true,
"error": null,
"error_code": null,
"function_calls": [ { "name": "set_lights", "arguments": { "room": "living room", "on": true, "brightness": 30 } } ],
"suppressed_calls": [],
"reasoning": "'living room' -> room; 'dim' -> on true, brightness 30",
"confidence": 0.94,
"prefill_tps": 4300.0,
"decode_tps": 850.0,
"peak_ram_mb": 88.5
}function_callsis a list of{"name", "arguments"};argumentsis grammar-guaranteed to match your schema. Empty is the refusal for off-topic input.suppressed_callsholds a call the engine withheld: confidence under 0.1, or a grounding gate. Show it to confirm, or treat the turn as a refusal.reasoningis the model's short, unconstrained derivation of each argument from its source span.confidenceis calibrated for the base model; it isNonefor tuned weights.validation.ungrounded, when present, liststool.fieldnames whose value the package could not ground, including a date whose year appears nowhere in the conversation or thesystemfacts.run()refuses those calls with{"error": "ungrounded field"}unlessstrict=False.
The contract
- A request no declared tool can serve is refused with the empty call
[]. There is no free-text fallback; always handle the empty case. - Arguments contain only values evidenced by the input. An optional field with no evidence is omitted, not guessed; do not assume a key exists.
- Arguments arrive after the engine's deterministic repair, grounded in the request: split names, completed phone numbers, verbatim quotes, recovered place queries, weekday and
March 4thdates against thedate:fact, polarity from the request verb, enums moved to the option the request names, routes fromfrom X to Y,by Ninto a lone number argument, invented required slots refilled from the request or the schema default, ungrounded optional values dropped, and calls the request excludes or negates dropped. - One toolset per agent. Later turns are bare queries against the same tools;
reset()rewinds the conversation. New tools mean a newCactus(...). Cactus(system=...)prefixes the localdate:fact automatically unless the text already carries one;auto_date=Falseopts out.
System facts
agent = cactus.Cactus(tools=tools, system="date: 2026-07-21 Tue 14:30; locale: en-US; device: phone; battery: 62%")Recognised keys: date, locale, device, battery, network, location, user, assistant. Facts, never instructions: the model resolves relative language against them and is not steered by prose placed there. Omitting the turn is safe.
Toolsets and custom weights
Needle 3 reads the complete toolset in its prompt. Keep tool names plain and toolsets small. It has no retrieval head.
weights="tuned.cact" loads an archive from cactus build. Each tuned text agent runs in its own worker process with its own model and conversation. Base and tuned agents can run together. Cactus 4 supports Needle 3, Whistle and Mirage. Needle 2 remains available through cactus-compute<4 and the GitHub repository's legacy branch.
Speech and vision
cactus.Whistle() transcribes WAV files or 16 kHz mono float samples. Use agent.complete(audio="clip.wav", language="en") for an audio tool call. Its result combines the tool-call envelope with audio_text and the speech metrics. Supply text or audio in each call.
cactus.Mirage() accepts file paths, encoded image bytes, Pillow images or RGB uint8 arrays. Packed RGB bytes require explicit width and height.
import cactus
vision = cactus.Mirage()
result = vision.describe("photo.png", sections=["objects", "text"], fields=["bbox", "mask"])
form = vision.extract("photo.png", {"animal": {"enum": ["dog", "cat"]}})describe accepts language, keywords and the native vision options, including confidence thresholds, object limits, output sections and mask format. extract accepts ordered fields with enum, bounded integer, string and box references. Text, speech and vision share one engine and calls are serialized across threads.
Offline devices
The engine comes from Cactus-Compute/cactus, with a pinned commit and SHA-256 checksums. Weights have independent pins in the Needle 3, Whistle and Mirage repositories. Engine files are cached under ~/.cache/cactus/engine/<version>/<commit>/ and models under ~/.cache/cactus/models/<model>/<commit>/. XDG_CACHE_HOME can relocate the cache.
- Run
cactus fetchon a connected machine to download the engine.--platform-tag manylinux2014_aarch64selects another runtime. Runcactus download needle3,cactus download whistleorcactus download miragefor the weights. - Copy the cache to the device, or use
--out <dir>and setCACTUS_LIB_PATHto the shared library andCACTUS_NEEDLE_WEIGHTS,CACTUS_WHISTLE_WEIGHTSorCACTUS_MIRAGE_WEIGHTSto the corresponding.cactfile. - Download the Python package and dependencies with
pip download cactus-compute, then install withpip install --no-index --find-links <dir> cactus-compute. SetHF_HUB_OFFLINE=1so missing files fail immediately.
A platform download holds binaries only. cactus download linux-arm64 --model needle3 --out ./runtime also places the model beside the engine. Native inference needs no network connection.
Environments
cactus.environments ships six ready-made tool surfaces: smart_home, media_player, productivity, wearable, kitchen_appliance and data_capture. Each module exposes TOOLS, SYSTEM, a lazily constructed agent, a frozen TEST_CASES suite of 32 cases and run_tests(min_confidence=0.0); python -m cactus.environments.smart_home runs one suite and exits 0 on pass.
from cactus.environments import smart_home
smart_home.agent.complete("dim the study lights to 30 percent")
smart_home.run_tests(min_confidence=0.4)Adapt one by swapping the Literal values for your own and keeping the shapes: closed sets as enums, bounded numbers, verbatim copy for free text, five tools or fewer.
CLI
| Command | What it does |
|---|---|
cactus generate-data --tools tools.json --num-samples 500 --output data.jsonl | Synthesise training rows through OpenRouter (--augment data.jsonl expands a set). |
cactus finetune data.jsonl --epochs 10 --out adapter.safetensors | LoRA on the frozen base; --lora-rank, --lora-alpha, --lr, --batch-size, --max-len, --val-split, --seed, --generate <n>. |
cactus build --lora adapter.safetensors [--layers N] [--platform <folder>] [--bits 2|4] [--upload] --out <path> | Merge, slice, quantise, package the tokenizer; with --platform, also fetch that platform's engine and header. |
cactus fetch [--platform-tag <tag>] [--out <dir>] | Pre-download the engine wheel for this or another machine. |
cactus download needle3 | needle3.safetensors | <org>/<repo>[/<file>.cact] | <platform> [--out <dir>] | Pull the base archive, the checkpoint to fine-tune, a published archive, or a platform folder. |
cactus playground [--weights my.cact] | The browser UI locally at http://127.0.0.1:7860. |
cactus whistle playground [clip.wav] [--language en] [--keywords "..."] [--word-timestamps] | Speech to text in the terminal: Enter to record, Enter again to stop, or pass a WAV. Needs the [mic] extra to record. |
cactus whistle compare [clip.wav] | The same clip through Whistle, Whisper tiny, Whisper base and Moonshine tiny v2, with each one's timing. Needs the [mic,compare] extras. |
cactus whistle finetune train.jsonl validation.jsonl test.jsonl [--tokens <file>] [--language <code>] [--epochs 3] --out adapter.safetensors | LoRA on the frozen Whistle base, scored on the test set; a new language code adds that language, --tokens adds words or characters to the vocabulary. |
cactus whistle build --lora adapter.safetensors [--layers N] [--platform <folder>] [--upload] --out <path> | Merge, slice, quantise to 4 bits and package a tuned whistle.cact; with --platform, also fetch that platform's engine and header. |
cactus download whistle [--out <dir>] | Pull the speech weights. |
cactus run --checkpoint <base> --query "..." --tools tools.json | JAX reference inference from a checkpoint, for development. |
Mistakes to avoid
- Expecting free-text answers. Unsupported input returns an empty call; handle it.
- Reading
argumentskeys that were not evidenced in the input. Optional fields may be absent. - Creating one
Cactusper turn. Reuse the instance so context carries; a new toolset is a new instance. - Passing a
.safetensorsor.pkltoweights=. It expects a.cactfromcactus build. - Loading an archive exported by an older package. The
.cactformat is tied to the engine version; rebuild it. - Assuming every custom archive has a calibrated confidence head. Archives without one return
confidence=None; validate their outputs yourself.
