Skip to main content

mechanicalai

The official Python library for the Mechanical AI API: an engineering problem solver with memory. You give it a problem and your files; it returns a report, models, tables and data, with progress while it works. The same engine as the app, called from your own code with an API key.

pip install mechanicalai

Python 3.10 to 3.13. Sync and async clients, typed models, a mechanicalai command.

The API is in preview. This library follows the published contract (spec/public-api.openapi.yaml); the version is 0.0.2 and the surface may still move before 1.0.

Quickstart

import mechanicalai
client = mechanicalai.Client()                      # MECHANICALAI_API_KEY
project = client.projects.create(name="Motor bracket, line 3")
plate = client.files.upload(project.id, "mounting-plate.step")
run = client.runs.create(project.id, engine="mekint", files=[plate.id], budget_cents=3000,
    problem="Design a steel bracket that mounts the 12 kg motor on the attached plate using the four M8 holes. "
            "Keep it under 1.2 kg and the first mode above 80 Hz. Check static stress at 3x load.")
for event in client.runs.stream(run.id):
    print(event.at, event.text or event.status)
run = client.runs.retrieve(run.id)
print(run.status, run.cost_cents, run.summary)
for artifact in client.artifacts.list_for_run(run.id):
    client.artifacts.download(artifact.id, "./out")

A project is the durable container; a run is one job on it. A later run on the same project continues with the engine's memory of it, so the next problem starts where the last one ended. Money is integer US cents everywhere (budget_cents, cost_cents); budget_cents is the most one run may charge.

Authentication

A key looks like mk_live_... or mk_test_... (40 characters after the prefix). The client reads it from MECHANICALAI_API_KEY, or takes it directly:

client = mechanicalai.Client(api_key="mk_test_000000000000000000000000000000000000test")

The key is sent only as Authorization: Bearer to the API host. It never goes to the signed upload and download URLs on other hosts, is never written to disk and is redacted from debug logs.

MECHANICALAI_BASE_URL (or base_url=) points the client at another host; the default is https://api.mechanicalai.com/v1.

The client

client = mechanicalai.Client(
    api_key=None,            # default: MECHANICALAI_API_KEY
    base_url=None,           # default: MECHANICALAI_BASE_URL or https://api.mechanicalai.com/v1
    timeout=None,            # default httpx.Timeout(60.0, connect=5.0); a float or an httpx.Timeout
    max_retries=2,           # retries after the first attempt
    default_headers=None,    # sent with every request
)
other = client.with_options(timeout=120, max_retries=5)   # a copy sharing the connection pools
with mechanicalai.Client() as client: ...                  # closes the pools on exit

All arguments are keyword-only. Resources, one attribute each: client.engines, client.projects, client.files, client.runs (and client.runs.events), client.artifacts, client.usage. Every method returns a typed pydantic model (or a page of them); unknown fields the API adds later are kept, never an error, and so is an enum value the library does not know yet (a new run status or event kind parses as a plain string). model.request_id is the X-Request-ID of the answer; quote it in support.

A None argument means "not given" and is left out of the request, everywhere except projects.update, where mechanicalai.NOT_GIVEN is the sentinel and name=None sends null to clear the name.

Async

AsyncClient has the same surface, awaited:

import asyncio, mechanicalai

async def main() -> None:
    async with mechanicalai.AsyncClient() as client:
        run = await client.runs.create_in_new_project(engine="mekint", budget_cents=2000,
                                                       problem="A 2 kg gripper with 300 mm reach.")
        async for event in client.runs.stream(run.id):
            print(event.kind, event.text or event.status)
        run = await client.runs.wait(run.id)
        print(run.summary)

asyncio.run(main())

Runs

run = client.runs.create(project.id, engine="mekint", problem="...", budget_cents=3000,
                         files=[plate.id], instructions="Use S355 unless told otherwise.",
                         units="metric", language="en", allow_web=False,
                         end_user="plant-3", metadata={"ticket": "ENG-4471"},
                         webhook_url="https://example.com/hooks/mechanicalai")
run = client.runs.create_in_new_project(engine="mekint", problem="...", budget_cents=3000)  # names the project itself
run = client.runs.retrieve(run.id)
run = client.runs.wait(run.id, poll_interval=2.0, timeout=3600)   # until terminal; WaitTimeoutError after timeout
run = client.runs.cancel(run.id)                                  # stops at once; charged for what ran
page = client.runs.list(project.id)

engine is "mekint"; the run's engine_version says which snapshot ran. A run ends in one of five states: succeeded, needs_input (the engine asked one question, in run.question), budget_exhausted, failed (run.failure.code) or cancelled. Answer a question with a reply, which starts a new run on the same project:

if run.status == "needs_input":
    follow_up = client.runs.reply(run.id, text="Steel, 10 mm.", budget_cents=2000)

One run at a time per project: a second one raises ProjectBusyError (409 project_busy).

Streaming events

runs.stream yields the run's events as they happen (server-sent events) and stops after the terminal status:

for event in client.runs.stream(run.id):
    if event.kind == "progress":
        print(event.text)
    elif event.kind == "task":
        print(event.text, event.percent, "%")
    elif event.kind == "status":
        print("->", event.status)

Kinds: phase (thinking, working), progress, task (with percent and eta_s), note, status; a kind the library does not know is passed through. If the connection drops, the stream reconnects with Last-Event-ID (exactly the last id received) and continues without a gap; the wait is the stream's own retry: hint when it sent one, else 1 s doubling to 8 s, and it gives up after max_reconnects (5) drops in a row without an event. Opened on a run that already ended, it replays every event. The same events are also a paged list: client.runs.events.list(run.id).

Uploading files

f = client.files.upload(project.id, "mounting-plate.step")                 # a path
f = client.files.upload(project.id, open("loads.xlsx", "rb"))               # an open binary file
f = client.files.upload(project.id, data, name="notes.txt")                 # bytes with a name
f = client.files.upload(project.id, ("plate.step", data), content_type="model/step")

Up to 64 MB a file goes in one multipart request; above that (or when the API answers 413) the library asks for a signed upload URL, PUTs the bytes there with the sha256 digest declared, so the API verifies them at complete, and completes the file. A path or a file backed by a real file descriptor is hashed and streamed from disk, never held whole in memory; an open file is read from its current position. The file comes back ready and can be attached to a run by id. files.list, files.retrieve, files.download(file_id, dest) and files.delete are there too.

Downloading artifacts

What a run produced, flat: a report, models, tables, data, images.

for artifact in client.artifacts.list_for_run(run.id):
    print(artifact.kind, artifact.name, artifact.formats)
    client.artifacts.download(artifact, "./out")                     # ./out/<name>.<default_format>
client.artifacts.download(run.report_artifact_id, "./out", format="pdf")
client.artifacts.download(model.id, "./parts/bracket-v2.stl", format="stl")   # an exact path
link = client.artifacts.download_url(model.id, format="glb")     # a signed link, valid 10 minutes

download fetches a fresh link and streams the bytes to disk; it returns the path written. A directory dest (existing, ending with a separator, or without a file extension) takes <name>.<format>, with the name made safe for any file system (a bare name, no control or reserved characters, no reserved Windows stems); anything else is the file path. The bytes land in a temporary file beside the target and are moved into place once complete, so a half-written file is never left under the final name. A model or table format is composed on first request: the API answers 202 preparing and the library waits and retries (up to timeout, 5 minutes by default). artifacts.list_for_project(project.id, kind="model") lists across runs; artifacts.retrieve and artifacts.delete complete the set.

Errors and retries

Every error the library raises is a MechanicalAIError:

  • APIConnectionError (and APITimeoutError): the request got no answer.
  • APIStatusError: an error answer, with status_code, code, type, param, message, request_id, body and, on limit refusals, limit and count. Its subclass follows the status: BadRequestError (400), AuthenticationError (401), PaymentRequiredError (402), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), GoneError (410), UnprocessableEntityError (422), RateLimitError (429, with retry_after), InternalServerError (5xx); any other status is a plain APIStatusError. Five are chosen by code: InsufficientCreditsError (402 insufficient_credit), ProjectBusyError (409 project_busy), IdempotencyAnswerLostError and IdempotencySupersededError (409, see below), and EngineRetiredError (410 engine_retired, reserved for the day snapshots can be pinned; the API sends no 403, 410 or 422 today).
  • WaitTimeoutError, WebhookSignatureError.

Handle by code; messages are for people and not stable.

try:
    run = client.runs.create(project.id, engine="mekint", problem="...", budget_cents=3000)
except mechanicalai.InsufficientCreditsError as e:
    print("top up the API wallet:", e.message)
except mechanicalai.ProjectBusyError:
    print("wait for the live run, or cancel it")
except mechanicalai.APIStatusError as e:
    print(e.status_code, e.code, e.request_id)

Connection errors, timeouts, 429 and 5xx answers are retried with exponential backoff and jitter (0.5 s doubling to 8 s), honouring Retry-After up to 60 s, up to max_retries (2) times. A POST that carries no Idempotency-Key (runs.cancel, files.complete) is the exception: it is never sent again after a connection error, a timeout or a 5xx, because the server may already have acted on it; only its 429 is retried. Nothing else is retried, so a 409 project_busy or a 400 surfaces at once. The same policy covers the signed upload and download URLs: a failed PUT is resent whole from its source, a failed GET asked for again.

Every create (projects.create, files.upload, runs.create, runs.create_in_new_project, runs.reply, files.create_upload_url) carries an Idempotency-Key: yours (idempotency_key="order-4471", 1 to 64 characters of [A-Za-z0-9._:-]) or one the library generates, kept across retries, so a retried create never makes a second object. The key is scoped to your account for 24 hours and fingerprinted with the request:

  • the same key and request again replays the first answer (Idempotent-Replayed: true);
  • a 409 idempotency_in_progress (the first is still being answered) is sent again by the library after its Retry-After (2 s), same key, same request, for up to 30 s regardless of max_retries; if it is still in progress after that, ConflictError is raised, and it is safe to send the same request with the same key again later;
  • a 409 idempotency_answer_lost or idempotency_superseded means the create happened but its answer is not coming back on this key: the library raises IdempotencyAnswerLostError / IdempotencySupersededError (both ConflictError) and never resends, with this key or a new one, because that would make and charge a second run. Read the resource instead (client.runs.list(project_id));
  • a 409 idempotency_conflict is a different request under a used key: ConflictError.

Only 2xx, 400, 403, 404 and 422 answers are kept for replay. A 401, 402, 409, 413, 429 or 5xx is not, so the same key sent again once the cause is fixed (a top-up after a 402 insufficient_credit) runs the request anew.

Timeouts

timeout is the httpx timeout for every call: by default 60 s to read, write and acquire a connection and 5 s to connect (httpx.Timeout(60.0, connect=5.0)); a float sets all four. Event streams carry a keep-alive every 15 s, so the read timeout only trips when the connection is really gone, and the stream reconnects. runs.wait(timeout=...) and artifacts.download(timeout=...) are separate, longer limits on how long to keep polling.

Pagination

Every list is a page with data, has_more and next_cursor; iterating the page walks every following page too:

page = client.projects.list(limit=50)
page.data                                   # this page only
for project in page: ...                    # every project, fetching pages as needed
page.next_page()                            # or step by hand
page = client.projects.list(after=page.next_cursor)

With AsyncClient, async for item in page does the same (an AsyncPage has no sync iteration; page.data is this page).

Webhooks

A run with webhook_url posts its terminal event (run.succeeded, run.needs_input, run.budget_exhausted, run.failed, run.cancelled) to that https URL, signed with the key's webhook secret from the console. Verify the delivery with the raw body and the headers:

from mechanicalai import webhooks, WebhookSignatureError

try:
    event = webhooks.verify(request.body, request.headers, secret=WEBHOOK_SECRET)   # body: the raw bytes
except WebhookSignatureError:
    return 400
run = event.data.run          # a full Run
event.type                    # "run.succeeded"
event.key_id                  # which key's secret signed it (Mechanicalai-Key-Id)

The check is a constant-time HMAC-SHA256 of "<t>.<body>" from Mechanicalai-Signature: t=...,v1=..., and a signature older than tolerance_s (300) is refused; key_id is read only after the signature checked out. A delivery that gets no 2xx is retried after 5 s, 1, 5, 15 and 60 minutes (six attempts in all); a receiver must tolerate a duplicate Mechanicalai-Event-Id.

Usage and limits

usage = client.usage.retrieve(period="month", group_by="end_user")
usage.runs, usage.cost_cents, usage.buckets, usage.by_end_user["plant-3"].cost_cents
limits = client.usage.limits()
limits.projects_per_account, limits.files_per_run, limits.usage.running_runs_this_key

CLI

The package installs a mechanicalai command (MECHANICALAI_API_KEY in the environment, or --api-key):

mechanicalai solve "Design a bracket for the attached plate." --file plate.step --budget-cents 3000 --out ./out
mechanicalai solve "Re-check it at 5x load." --project prj_... --language ko
mechanicalai runs get run_...
mechanicalai runs list --project prj_...
mechanicalai runs cancel run_...
mechanicalai artifacts download art_... --format pdf --out ./out
mechanicalai projects list | get prj_... | delete prj_...
mechanicalai usage --period day
mechanicalai limits
mechanicalai --version

solve creates a project (or uses --project), uploads the files, starts the run, prints events as they stream, downloads every artifact into --out and prints the summary. Exit codes: 0 on success, 1 on an API or connection error, 2 on a usage error.

Logging

MECHANICALAI_LOG=info logs every request and answer (method, URL, status, request id) to the mechanicalai logger; debug adds headers and bodies, with the key and the query strings of signed URLs redacted.

Versioning

Semantic versioning. Before 1.0, a minor version may change the surface; a deprecation is announced one minor version ahead of its removal, in CHANGELOG.md and with a DeprecationWarning. The library is built against the API contract in spec/; new fields in the API never break an older library, because every model keeps unknown fields.

Supported Python: 3.10, 3.11, 3.12, 3.13.

Contributing

See CONTRIBUTING.md. Security reports go to security@mechanicalai.com (SECURITY.md).

License

Apache-2.0. See LICENSE.

Metadata

Release files for mechanicalai 0.0.2

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

Source distribution (sdist)

Source distribution for mechanicalai 0.0.2
File Size Uploaded Provenance Actions
mechanicalai-0.0.2.tar.gz 74.7 kB With provenance Details for mechanicalai-0.0.2.tar.gz

Built distribution (wheel)

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

Total release size: 131.2 kB

Release files / mechanicalai-0.0.2.tar.gz

Download URL mechanicalai-0.0.2.tar.gz
Size 74.7 kB
Tags Source
SHA-256 checksum
How to use checksums
40691b444fbda77c2a7a09355c84601cd2081c410e95c2ff9ac7569e289ef698
BLAKE2b-256 checksum
How to use checksums
d29139208a369ad07199d8e6a83d8b6f46c62a66e73a9cfa7a9e566194b336cd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

Release files / mechanicalai-0.0.2-py3-none-any.whl

Download URL mechanicalai-0.0.2-py3-none-any.whl
Size 56.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
19e92406e6a401ecffd0acf4f950bf3def1b427fc9bf4a1e6c9208aef264b07f
BLAKE2b-256 checksum
How to use checksums
be333bf1ee8b2a9506d8d1d259a00437b21243984b6ab57cb8604d3782ae9a93
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

Security for mechanicalai 0.0.2

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.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for mechanicalai-0.0.2.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 6, 2026.

Transparency log

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