The short version
- Call it after you have assembled the context and before you send it to the model. Never on the system prompt, the user turn or tool schemas.
- Send assembled packets and tool output as
textwithbudget.autochunk. Sendrecordswhen you already hold units you want kept whole and ids you need back. - Size record text against 100,000 tokens; the request is refused at 128,000, counted over everything you send.
- Set
max_context_tokensfrom what your model call can accept and leave it there. Pinoperating_point. Supplyrequest_idand record ids. - A stand-down is a
200withfallback_used: true. Send what you submitted. Do not retry it, do not alarm on it. - Fail open on every error path in your own code, and run shadow mode on real traffic before you change a prompt.
Where the call sits
Needlepath selects from the context you were about to send. It sits on the hop between your context assembly and your model call: retrieval, graph traversal, tool execution and memory lookups have run; the packet exists; the model has not read it yet.- Usually one call per model call. An agent loop makes the call on every turn that carries new tool output or retrieved context; a pipeline makes it once per decision step. The published adapters also sit at a tool-result or retrieval seam where that is the natural place (integrations). The request is stateless; nothing is kept between calls.
- Only candidate context goes in. The system prompt, the current user turn,
tool and function schemas, and anything that must reach the model stay out of
the request. Concatenate them around
rendered_contextyourself. They do not benefit from selection, and nothing in the request pins a record today (why).
Choose the payload shape
The endpoint accepts two shapes. They are not interchangeable, and the choice is the first thing to get right.
Records are still the right shape when the fields they carry matter.
kind
is read; title and source come back on the selection; a stable id is
what lets you resolve a selection to your own objects with byte fidelity.
text records have none of that: content and a hash. Choose by what you need
back, not by what is easiest to serialise.
Size the request
The ceiling is checked on the whole request as counted in Limits. Titles, the task prompt andrecent_prompts count alongside record text.
When a packet is over the ceiling, pre-filter with your retriever so that
fewer candidates arrive, or split it along its natural boundaries into several
requests. If you merge the selections from several requests, size each
request’s budget so the merged result still fits your downstream window: the
budget and the record cap apply per request, not to the merge. Do not shrink a
packet by concatenating records into fewer, larger ones; that trades a
413
for a stand-down.
Three fields people reach for to get under the ceiling, and why not to:
attributes,keywords,tagsare not counted, and nothing parked there can be selected or returned. They are hints with a published size cap. Content goes intext.recent_promptsis counted and does not affect selection. Send a handful or none, never the transcript.titleis counted. Keep it a short label.
Set the budget from the downstream window
max_context_tokens is the token budget the selection has to fit inside. Set it
from what your model call can accept after the system prompt and the user turn
are added, and leave it there.
- Do not sweep the budget looking for a quality optimum. Selections at different budgets are different computations; a larger budget is not a superset of a smaller one.
- Use
mode: "adaptive"when difficulty varies across requests and you would otherwise overspend on the common case or under-serve the hard one. Each rung is a full pass, so the cost is latency. Watchattempted_budget_tokens(adaptive budget). max_recordscaps the selection, not the request. It is the right field when your downstream step can take at most N items.
Pin the operating point and keep what it returns
Always sendbudget.operating_point. An unpinned request inherits the server
default, and the default moves when a new label is minted. Pin it in your
client’s constructor, not per call, and read the registry at
/v1/operating-points when you want to move.
Keep policy_version from every response that carries one. It names the
frozen configuration that produced the selection, and it is how you notice
that a request ran on a label you did not choose. Keep your own request_id on every call, built from
something deterministic on your side (session, step, attempt), so a response
can be matched to the decision it served.
The same request under the same operating point returns the same selection,
and record order is part of the request. Send records in a stable order.
Handle a stand-down
Needlepath does not force a selection on every call. When it cannot find evidence that a smaller context covers the task, it hands your context back and says so. On the current operating points that is a200 with
fallback_used: true, every record returned, and usage.outcome reading
pass_through; on older operating points it is an empty selection with
outcome: "stood_down" (both shapes).
- Send what you submitted. Use your own copy of the packet, or
rendered_context, which returns it essentially intact. - Do not retry with a smaller budget. A stand-down is an answer, not a budget error.
- Do not alarm on it, and do not read
tokens_saved: 0as a failure. On some workload shapes it is the common outcome. Track the engage rate as a property of the workload, in shadow mode first. - Alarm on
selection_error. That field is set when the selection pass itself raised; the status is still200and the body still carries a usable full-context result. It is the one case among the four that producefallback_used: truewhich is an outage rather than an answer (reading a stand-down).
outcome, usage.outcome, selection_error and
format_metrics.engine_fallback_reason if you need to tell the cases apart.
fallback_used alone does not.
Read the response
rendered_contextis the model-facing block and is always returned. For most callers it is the whole answer.selected[]is for joining back. Setreturn_per_record: falseif you do not need it; the body gets materially smaller.- Join on
record_idagainst your own copy when you need exact bytes. Anexcerptis built from your content and never rewritten, but it may be non-contiguous, whitespace-normalised, or truncated with a...[truncated]marker. Sendrender_format: "plain"if you re-parse excerpts as JSON. scoreis comparable within one response only. Rank with it; do not threshold on an absolute value or compare across requests.reasonis open-ended andoutcomeis an open enum. Show them in a debugging view; do not branch on exact strings, and do not fail closed on a value you have not seen.records_expandedis present only when you sentautochunk. A200on a request that set it means the operating point accepted and ran it; an operating point that does not support it refuses with400.
Log enough to replay
Keep the request as you sent it, and these fields from the response, and a selection can be re-requested later and explained without re-running anything:
The request as sent, including the operating point and the record order, is
what makes the re-request possible; the fields above are what let you state
which records reached the model and which did not.
Fail open in your own code
These are the rules our own adapters follow, and they are the right rules for yours (integrations):- A timeout, a non-2xx or an unreadable body means send the original context
unchanged and log a metadata-only warning. On a
200, followfallback_used; do not infer anything from the length ofselected[], which is also empty when you setreturn_per_record: false. - Never send a credential, a prompt or record text to your own logs on that path.
- Pin the operating point in the constructor.
- Guard the 6 MB body limit before sending.