\n
OpenSpec Index
GET IN TOUCH

Questions, pilots, API access. One email reaches the builder.

Open mail app

Python client pip install openspec. No key for the free tier, and no dependencies.

INSTALL

pip install openspec

Nothing else gets installed with it. The client uses only the Python standard library, on purpose: a parts lookup should not drag a dependency tree into a CAD plugin, a procurement script, or a locked-down corporate Python. Requires Python 3.9 or newer.

ONE CALL

from openspec import OpenSpec

os = OpenSpec()

for part in os.find("316 stainless nylon insert lock nut"):
    print(part.mpn, part.manufacturer.name, part.source_url)

find() takes plain words and resolves them against the real category tree. It also reports which words it could not apply, in ignored_words. Show those to your user: a query that quietly dropped half of what was asked looks identical to one that answered it.

result = os.find("cheap 4 inch grooved carbon steel tee")
result.total          # 9
result.family         # 'tee'
result.ignored_words  # ('cheap', 'carbon')

EVERY RECORD CARRIES ITS SOURCE

part = next(iter(os.find("1/4-20 stainless hex nut")))

part.source.url         # the manufacturer page this was read from
part.source.checked_at  # when we last read it
part.price_each
part.assets_of("cadFiles")

Cite the source. A specification with no source is a rumour, and removing rumours is the entire point of the index.

FILTERING BY SPECIFICATION

page = os.parts(family="tee", material_class="steel", run_size=4)

for part in page:
    print(part.mpn, part.spec("end_run_connection_type"))

A rating filters as a threshold, not an equality. This is the one behaviour worth reading twice. An attribute whose kind is atLeast compares with ≥, so asking for 175 psi also returns everything rated higher. There is no operator to write, because the direction belongs to the attribute rather than to your query.

os.count(family="elbow", pressure_working_max_psi=175)   # 8051
os.count(family="elbow", pressure_working_max_psi=1000)  # 2163
os.count(family="elbow", pressure_working_max_psi=5000)  #  338

Ask for what the job needs and better parts still qualify. To check which behaviour an attribute uses, read matches:

attr = os.category("elbow").attribute("pressure_working_max_psi")
attr.kind          # 'atLeast'
attr.is_threshold  # True
attr.matches       # 'Pass a number. Matches parts rated AT LEAST that number...'

pressure_class is not a pressure. It is an ASME designation, and a class is a curve against temperature: a Class 150 flange is rated 285 psi at 100°F and 75 psi at 800°F. Use it to identify a part, never to answer whether one is good for a given pressure.

PORTABLE ATTRIBUTE NAMES

Families spell the same concept differently. A tee has run_size, the reducer beside it has size_large_in, the elbow has size1_in. Portable names resolve to whichever the family uses, so one query shape covers many families.

for family in ("tee", "elbow", "reducer", "nipple", "coupling"):
    print(family, os.count(family=family, size_in=4, material_class="steel"))

os.category("reducer").resolve("size_in")   # 'size_large_in'

Available everywhere: size_in, size2_in, material_class, material_grade, finish, connection_type, connection_type2, schedule, thread, pressure_class, pressure_max_psi, temp_max_f, temp_min_f. The family's own names keep working.

EXPLORING CHEAPLY

count(), categories() and values() are free and unlimited. Add one filter at a time and watch the count. When it drops to zero you know exactly which filter did it, which is the whole debugging technique.

cat = os.category("valves")
[a.attr for a in cat.gates]          # the identity attributes
[t.type for t in cat.part_types][:5]

os.values("valves", "connection_type")   # every value, per type, with counts

PAGING AND CROSS-REFERENCE

for part in os.iter_parts(family="screw", thread_spec="1/4-20", max_parts=500):
    ...

matches = os.equivalents("SS-AFSF12")

On a cross-reference, read specsCompared and specsStated before trusting a match percentage. 100% agreement across one shared spec is not the same claim as 100% across twelve, and both numbers are returned so the difference stays visible.

ERRORS

The API refuses on purpose where an empty result would be a wrong answer, and its messages carry the legal values. The client keeps that text verbatim.

from openspec import BadRequest

try:
    os.parts(family="tee", material_class="carbon-steel")
except BadRequest as e:
    print(e)
    # unknown value "carbon-steel" for attr.material_class in family "tee".
    # Valid values: pvc, cast-iron, stainless-steel, copper, steel, brass, ...

BadRequest (400), NotFound (404), RateLimited (429), ServerError (5xx), all subclasses of OpenSpecError.

RATE LIMITS

Every IP gets 100 free data queries. Counts, categories and values are free and unlimited. Past the limit the API degrades rather than blocking: it answers with a match count and no rows, which the client surfaces as page.limited rather than raising. That is a real answer, not a failure.

page = os.parts(family="nut")
if page.limited:
    print(f"{page.total} matches, but out of free row pulls")

For a key, email [email protected]. Pass it as OpenSpec(api_key=...) or set OPENSPEC_API_KEY. It travels as a header, never in the URL.

NOT USING PYTHON?

The API is plain HTTP and JSON, so nothing here is required. The API reference covers every endpoint directly, and the API page has a live query console. Agents should start at llms.txt.