An invoice pasted into a chat, a card charge on a wrist, a support email, a form read aloud. Turning messy text into typed fields is half of what small on-device models get asked to do, and Needle does it with no separate mode: extraction is tool calling with exactly one tool. This guide is that mechanism, the helper built on it, and the rules that keep extracted values honest.
One tool, one call
Declare the record as the only tool and pass the passage where the query goes. With one declared tool the grammar admits exactly one call of that name, so schema conformance is guaranteed rather than requested, and the returned call's arguments are the extracted fields.
The Python helper does the declaration for you and returns a typed object:
from pydantic import BaseModel
import needle
class Invoice(BaseModel):
vendor: str
total: float
due_date: str
po_number: str | None = None
invoice = needle.extract("Invoice from Acme Corp, $1,200.00, due 2026-09-01", Invoice)
print(invoice.vendor, invoice.total) # -> Acme Corp 1200.0extract(text, schema) returns a Pydantic instance when schema is a model, a dict when it is a JSON schema, and None when nothing matched. Under the hood it reads the record from function_calls or, when the engine withheld it, from suppressed_calls, and validates it. The same works without the helper, which is also the shape the CLI runner takes as tools.json:
receipt = [{
"name": "receipt",
"description": "A purchase receipt shared as text",
"parameters": {
"type": "object",
"properties": {
"merchant": {"type": "string"},
"total": {"type": "number"},
"currency": {"type": "string"},
"line_items": {"type": "array", "items": {"type": "object"}},
},
"required": ["merchant", "total"],
},
}]
agent = needle.Needle(tools=receipt)
agent.complete("GreenMart receipt: oat milk 3.50, total 7.75 paid by visa")["function_calls"]
# [{'name': 'receipt', 'arguments': {'merchant': 'GreenMart', 'total': 7.75}}]What the grammar fixes
The decode grammar is compiled from your schema before the first token. Keys, quotes, braces and order are not predicted, they are forced; every value is bounded by its type, its enum, its range, its pattern or its length. The model's only decisions are the values, and even those cannot leave their constraints.
That is why the shape never needs post-processing. A bounded number cannot grow past its maximum, a numeric literal stops at twenty characters, an array stops after sixty-four items or seven identical consecutive ones, and a repeating token cycle inside a call is banned. Put the constraints in the schema and the invalid outputs stop existing.
What grounding keeps honest
The shape is guaranteed; the values are grounded. A field is filled only from a span of the passage. An optional field with no span is omitted rather than guessed, and comes back as None on the typed object. A required field with no span withholds the whole record into suppressed_calls, which extract() then reads and validates; strict=True (the default) additionally rejects temporal values that contradict the years written in the passage or in the system facts, and anything the engine itself reports as ungrounded.
The deterministic repair pass runs on extractions exactly as on calls: quoted text is restored verbatim, request casing is restored for free-text strings, a truncated phone number is completed, March 4th and weekday dates resolve against a date: fact when you give one, celsius becomes fahrenheit for a fahrenheit field, a file extension becomes a MIME type, and a number counts as evidence only as a whole number or a number word, so 72 never becomes a 2. Relative quantities need the reference in the passage: "double last month's" extracts nothing for amount unless last month's figure is in the text.
Classification is extraction with an enum
Declare a field with a closed set and the record becomes a classifier whose output cannot leave the set:
from typing import Literal
class Ticket(BaseModel):
sentiment: Literal["positive", "neutral", "negative"]
category: Literal["billing", "delivery", "product", "account", "other"]
urgent: bool
needle.extract("Third time my parcel is late and nobody answers. Cancel my account.", Ticket)
# Ticket(sentiment='negative', category='delivery', urgent=True)Extraction generalised to classification without special training because the enum is just a constrained value. A wearable reads a notification into merchant, amount and date the same way, then into a reply, then into a sentiment flag, one record each.
Dates, numbers and units
Give the model a date: fact when the passage uses relative language, and it resolves "next Tuesday" to a date; without one, the phrase passes through verbatim so the host can resolve it. Describe the format you want on the field ("ISO date", "City, ST", "USD, no symbol"), because the model copies what it is told to copy. Bound numbers with ge/le and the grammar enforces them; declare number for a float and integer for a count, since a number-typed integer renders as a float.
Confidence and batches
Every extraction carries the same confidence as a call, from the same calibrated head, and the routing guide applies unchanged: accept above your threshold, show the record for review below it, and treat None as "nothing here". For many passages, keep one agent and call extract() repeatedly; for many records in one passage, declare an array field on a single record rather than expecting several calls. Fine-tuning uses the same data format, with the record as the tool and the passage as the query, and the fine-tuning guide covers it.
