Skip to main content

needlepath — Python client

The framework-free core client for the Needlepath hosted context-selection service. HTTP only, zero required dependencies, one transport/auth seam.

Framework integrations are separate distributions and depend on this one:

Distribution Seam
needlepath-langchain AgentMiddleware (wrap_model_call, wrap_tool_call) + the LangGraph recipe
needlepath-litellm LiteLLM proxy CustomGuardrail
llama-index-postprocessor-needlepath BaseNodePostprocessor

Install

pip install needlepath
pip install "needlepath[httpx]"   # optional: httpx-backed async transport

Python 3.9+. No required runtime dependencies — the default transport is stdlib urllib, so installing this client can never move a version you already pinned.

Quickstart

from needlepath import NeedlepathClient, ContextRecord, TaskSpec

client = NeedlepathClient(
    api_key="np_live_...",  # or NEEDLEPATH_API_KEY
    operating_point="np-2026-08-r4",  # required — see "Pin the operating point"
)

result = client.select(
    records=[
        ContextRecord(text=tool_output, kind="tool_result", id="lookup-1"),
        ContextRecord(text=doc, kind="external_data", id="doc-7", title="SOP"),
    ],
    task=TaskSpec(prompt="what is the invoice total for A-1002?"),
    max_context_tokens=4000,
)

context = result.rendered_context if result.applied else my_original_context

Ordinary unprotected failures return result.applied=False, allowing your original context. A record your workspace policy withholds comes back as result.reason == "blocked" with result.applied=True and an empty rendered_context: the verdict is "deliver nothing from these records", and that is what you install. Workspace guardrail failures (the service could not enforce, or returned a protected response that cannot be applied) raise NeedlepathGuardrailError; stop the model request instead of substituting the original context.

Workspace guardrails

The framework integrations (LangChain/LangGraph, Strands, LiteLLM and LlamaIndex) install a blocked verdict as a withheld notice in place of the record, or as no context, and let the agent continue; they propagate NeedlepathGuardrailError rather than falling back to the original. guardrails_required is a core-client option: pass a client you constructed with it to the integration.

For a workspace with guardrails, update the SDK and construct either the sync or async client with guardrails_required=True:

client = NeedlepathClient(
    operating_point="np-2026-08-r4",
    guardrails_required=True,
)

This local fallback setting is never sent to the server and cannot disable workspace policies. It stops on transport errors, timeouts, invalid requests, and missing or malformed enforcement evidence. An authoritative admin pause (enabled=False) or active configuration with zero policies is honored.

Regardless of this setting, invalid governance metadata or guardrails_unavailable raises a sanitized NeedlepathGuardrailError. A blocked response never raises: it is applied as an empty delivery (reason: "blocked"), in shadow mode too, and the receipt's governance block says what was withheld and under which policy revision. When the server forbids raw-context fallback, only its permitted selected context is applied, including in shadow mode and after engine recovery. result.declined(...) cannot restore the original context for a protected response. Do not catch a guardrail exception or a response rewrite error and continue with raw records. Existing clients must update and enable this setting to stop safely when an outage prevents the server from returning any enforcement evidence.

Async

from needlepath import AsyncNeedlepathClient

async with AsyncNeedlepathClient(operating_point="np-2026-08-r4") as client:
    result = await client.select(records=..., task=..., max_context_tokens=4000)

Identical semantics — the decision logic is literally the same functions. Uses httpx.AsyncClient when httpx is importable, a worker thread otherwise; it never blocks the event loop either way.

R4 trace and outcome attribution

Pin np-2026-08-r4 and opt into the metadata-only trace when you need to inspect record dispositions:

result = client.select(
    records=records,
    task=TaskSpec(prompt=question),
    max_context_tokens=4000,
    return_trace=True,
)

if result.response and result.response.selection_trace:
    for item in result.response.selection_trace.records:
        print(item.record_id, item.disposition, item.reason)

The hosted response also supplies result.selection_id, a server-issued id separate from your request id. Report downstream quality after the model call:

if result.selection_id:
    client.report_outcome(
        result.selection_id,
        task_success=True,
        context_sufficient=True,
        evaluation_source="application",
    )

report_outcome() is idempotent and independently retryable. It can raise on a callback failure; that never changes select() or the context already returned. Do not send prompts, context, model output, answers, or free-form notes.

Configuration

Argument Environment Default
api_key NEEDLEPATH_API_KEY none (no Authorization header)
operating_point NEEDLEPATH_OPERATING_POINT required
base_url NEEDLEPATH_BASE_URL https://api.nextmoca.com
timeout — 10.0 s, total, retries included
max_retries — 2
max_request_bytes — 6 * 1024 * 1024
shadow — False
guardrails_required — False

The base URL is a documented default, not a hard-coded one: a durable custom domain replaces it, and swapping it is a config change rather than a release.

http:// is refused for any non-loopback host. TLS-only is an inherited binding constraint; loopback is exempt so the localhost benchmark adapter still works.

The four binding rules, and where they live

1. Fail open for ordinary unprotected requests

With the default configuration and no workspace protection evidence, a timeout, 5xx, violated contract, empty selection, or stood-down gate returns a SelectionResult with applied=False, the exception on .error, and a machine-readable .reason. The workspace guardrail contract above takes precedence over these optimization fallbacks.

if not result.applied:
    log.info("needlepath passthrough", extra=result.metadata())
    context = my_original_context

result.reason is an open enum (needlepath.Reason). Today:

reason applied meaning
ok yes safe to apply
shadow no shadow mode — measured, deliberately not applied
disabled no the integration was constructed disabled
no_records no nothing to select over; no call was made
below_trigger no under the configured trigger; no call was made
escalated no the engine declined: full context over capacity. Charged at the full rate, unlike a free stand-down
empty_selection no zero records selected
engine_fallback no fallback_used — the engine wants the full context
selection_error no the response carried a selection_error
no_per_record_detail no per-record detail was required and absent
payload_too_large no over the client-side guard; no call was made
timeout / transport_error / http_error / contract_error no error paths
unexpected_error no a defect in this client — still fails open

Treat an unrecognized reason as "do not apply". result.failed separates the error paths from the benign declines, which is the distinction that decides whether a passthrough deserves a WARNING or a DEBUG.

An ordinary empty selection degrades to passthrough. A blocked verdict is applied as an empty delivery (reason: "blocked", applied=True), never a passthrough and never an exception. An empty selection under workspace protection that is not a blocked verdict raises and must stop the model request.

select_or_raise() exists for diagnostics and benchmarks that must not silently substitute a passthrough for a measurement. Framework adapters use select() with its explicit guardrail exception contract.

2. Pin the operating point

operating_point is required at construction; an invalid configuration raises. Failing open on an ordinary unprotected bad request is degradation a customer absorbs; failing open on "no operating point was pinned" accepts exactly the behaviour drift the rule exists to prevent. It fails at wire-up, never under load.

Labels are immutable: np-2026-08-r4 means one frozen configuration forever, and a retune mints a new label. client.operating_points() lists what the endpoint serves — call it once at wire-up if you want the assertion, never per request.

3. Guard the 6 MB cap client-side

The serialized body is measured before it is sent. Over the guard, you get reason="payload_too_large" and no network call, rather than a platform error from the far side of the wire.

The guard measures the JSON body. Lambda's 6 MB limit applies to the whole invocation payload — the API Gateway event envelope sits on top — so a request that passes at 5.99 MB can still be rejected upstream. Lower max_request_bytes if you run near the cap.

4. Shadow mode is a one-line switch

client = NeedlepathClient(operating_point="np-2026-08-r4", shadow=True)

The call is made exactly as a live call would be, with the same body, billing, and operating point. Ordinary unprotected selections are reported without being applied (reason: "shadow"). A blocked verdict is enforced in shadow mode too: shadow never restores a withheld record, so the result stays reason: "blocked", applied=True, with shadow: true, outcome and governance on the receipt. A protected delivery likewise takes precedence: only its permitted selected context may be used, or the request stops with NeedlepathGuardrailError.

budget.mode = "shadow" is not on the wire yet. BudgetSpec.mode on the published contract accepts fixed and adaptive only; adding shadow is a public-repo contract change (§0.1, sequenced in E7). The wire half is behind needlepath.features.WIRE_SHADOW_MODE; flip it in the same change that lands the field. Client-side behaviour does not change when you do — it is already correct.

Retries

Retried: 429, 502, 503, 504, and transport failures. Retry-After is honoured in delta-seconds form. Backoff is full-jitter exponential, and every retry is bounded by the timeout deadline — retries can never exceed the budget the caller gave you.

500 is not retried. A 500 from this endpoint is engine_error: a deterministic failure on this exact input. Retrying buys nothing, doubles the latency before you fail open, and spends a second billable invocation on a request that will fail identically.

POST is safe to retry here because the endpoint is stateless and idempotent by contract (ADAPTER_API_DESIGN.md invariant 1).

Metadata

result.metadata() is a flat, metadata-only dict built for a framework's metadata blob:

{"needlepath.applied": False, "needlepath.reason": "engine_fallback",
 "needlepath.tokens_before": 8400, "needlepath.tokens_saved": 0,
 "needlepath.gate_reason": "standdown:flat_gap", ...}

Nothing derived from record text is in it. One consequence is non-obvious: selection_error is built server-side as f"{type(exc).__name__}: {exc}" and can carry fragments of your input in an exception message — the Lambda deliberately declines to log it for that reason — so only its type appears here. The full string stays on result.response.selection_error.

System One models (TypeSafe Jev, Laya)

Small classifier models that take {state, questions} and answer the questions against the state. needlepath.integrations.system_one reshapes a selection into that request body:

from needlepath.integrations.system_one import selection_receipt, to_system_one_state

result = client.select(records=records, task=task, max_context_tokens=4000)
payload = to_system_one_state(
    # Fail open with your own records: a result that did not apply is refused.
    result if result.applied else records,
    questions={"refund": Noul(instructions="Is this a refund request?")},
)
# Sidecar metadata, not part of the body.
receipt = selection_receipt(result)

payload["state"] is a JSON array of {record_id, title, source, text}, one entry per record in response.selected, in the order the service returned them, with the text unmodified. The adapter is transport only: no threshold on score, no drop for an empty excerpt, no re-sort, no deduplication, and no question you did not pass. mode="rendered" returns state as a single string instead, records joined by blank lines under a one-line [record_id] title header, for a classifier whose state argument is text.

questions travels in the shape you pass it. langchain-typesafe reads a mapping of id to typed question (Noul, Choice, Score); a different {state, questions} service may read a list. Both are copied and sent unchanged, so a mapping never arrives as a list of its keys. A bare string is refused rather than iterated into single-character questions.

to_system_one_state accepts a SelectionResult only when it applied. A result that did not apply is a passthrough — your own records are what has to be sent — and its response can still carry a selection the client itself declined to use, so passing one raises NeedlepathConfigError naming the reason. To reshape that response anyway, for an audit or a shadow report, pass result.response.

selection_receipt() returns request_id, selection_id, policy_version, tokens_saved and outcome, prefixed the way result.metadata() prefixes its keys. Attribution travels beside the request; record text never travels in it.

Testing your integration

from needlepath.testing import FakeTransport, response_payload, ok, error

transport = FakeTransport([error(503), ok(response_payload("np-1"))])
client = NeedlepathClient(operating_point="np-2026-08-r4", transport=transport)

needlepath.testing is public API, not a private helper. Every rule above is a rule about what happens when the service misbehaves, and you cannot test that against a healthy service.

Wire contract

INTERFACE.md in context-selection-bench is the published contract; the dataclasses here are an independent implementation of it, deliberately copied rather than imported so a research harness never lands in a customer's dependency tree. tests/test_wire_compatibility.py asserts they have not drifted.

Unknown response fields are kept on response.extra, never rejected: /v1 is additive, and usage (E2) is already scheduled to arrive that way.

The gate outcome and reason are an open enum. Nothing in this SDK branches on them and nothing you build on it should — a third outcome is a planned additive change.

Metadata

Release files for needlepath 0.2.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for needlepath 0.2.3
File Size Uploaded Provenance Actions
needlepath-0.2.3.tar.gz 80.2 kB Without provenance Details for needlepath-0.2.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for needlepath 0.2.3
File Interpreter ABI Platform Actions
needlepath-0.2.3-py3-none-any.whl Python 3 none any Details for needlepath-0.2.3-py3-none-any.whl

Total release size: 135.7 kB

Release files / needlepath-0.2.3.tar.gz

Download URL needlepath-0.2.3.tar.gz
Size 80.2 kB
Tags Source
SHA-256 checksum
How to use checksums
4798ef266f53b38e43b2bea5d3ea1240cafa13a81850ae44e78cf1b870236807
BLAKE2b-256 checksum
How to use checksums
9b9f0054c708bd507418bd92b858eec406723d33fe17dafd1bc28857e9b59dc7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.11

Release files / needlepath-0.2.3-py3-none-any.whl

Download URL needlepath-0.2.3-py3-none-any.whl
Size 55.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
2a6914cc586382c72ec09c5ce70b02ae5a7f50b86709cdc1a3aac124bc0c19eb
BLAKE2b-256 checksum
How to use checksums
ff684fae327216c8f92752e0141753c4bad82a5bb7be077fe8486ba0d9ac46ec
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.11

Security for needlepath 0.2.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page