Back to blog
GuidesPythonInferenceTool CallingSpeech RecognitionVLM

Needle Python Docs

The reference for the cactus-compute package: the Cactus class, tools three ways, the response shape, the behaviour contract, system facts, speech, vision, tuned weights, offline devices, environments, the CLI, and the mistakes to avoid.

RS

Roman Shemet

12 min read

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 Silicon

import 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

CallWhat 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.toolDecorator 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

What run() does for youthe same loop you can drive by hand with complete()up to max_stepsyour queryagent.run("...")Needletype: call ยท function_callsyour functionexecuted by run()result fed backcomplete(json.dumps(out))Needleanother call, or respondresultson the final responseno free text is generated: the answer is the tool results on the final respond turn
run() executes each call against your decorated functions, feeds the JSON result back as the next turn, and stops at a respond turn or max_steps. complete() gives you one turn at a time when you want to own execution.
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_calls is a list of {"name", "arguments"}; arguments is grammar-guaranteed to match your schema. Empty is the refusal for off-topic input.
  • suppressed_calls holds a call the engine withheld: confidence under 0.1, or a grounding gate. Show it to confirm, or treat the turn as a refusal.
  • reasoning is the model's short, unconstrained derivation of each argument from its source span.
  • confidence is calibrated for the base model; it is None for tuned weights.
  • validation.ungrounded, when present, lists tool.field names whose value the package could not ground, including a date whose year appears nowhere in the conversation or the system facts. run() refuses those calls with {"error": "ungrounded field"} unless strict=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 4th dates against the date: fact, polarity from the request verb, enums moved to the option the request names, routes from from X to Y, by N into 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 new Cactus(...).
  • Cactus(system=...) prefixes the local date: fact automatically unless the text already carries one; auto_date=False opts 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.

  1. Run cactus fetch on a connected machine to download the engine. --platform-tag manylinux2014_aarch64 selects another runtime. Run cactus download needle3, cactus download whistle or cactus download mirage for the weights.
  2. Copy the cache to the device, or use --out <dir> and set CACTUS_LIB_PATH to the shared library and CACTUS_NEEDLE_WEIGHTS, CACTUS_WHISTLE_WEIGHTS or CACTUS_MIRAGE_WEIGHTS to the corresponding .cact file.
  3. Download the Python package and dependencies with pip download cactus-compute, then install with pip install --no-index --find-links <dir> cactus-compute. Set HF_HUB_OFFLINE=1 so 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

CommandWhat it does
cactus generate-data --tools tools.json --num-samples 500 --output data.jsonlSynthesise training rows through OpenRouter (--augment data.jsonl expands a set).
cactus finetune data.jsonl --epochs 10 --out adapter.safetensorsLoRA 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.safetensorsLoRA 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.jsonJAX reference inference from a checkpoint, for development.

Mistakes to avoid

  • Expecting free-text answers. Unsupported input returns an empty call; handle it.
  • Reading arguments keys that were not evidenced in the input. Optional fields may be absent.
  • Creating one Cactus per turn. Reuse the instance so context carries; a new toolset is a new instance.
  • Passing a .safetensors or .pkl to weights=. It expects a .cact from cactus build.
  • Loading an archive exported by an older package. The .cact format 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.

Cite this post

@misc{shemet2026needle,
  title        = {Needle Python Docs},
  author       = {Shemet, Roman},
  year         = {2026},
  month        = sep,
  howpublished = {Cactus Compute blog},
  url          = {https://cactuscompute.com/blog/needle-python-docs}
}