multinodal
The Python client for the Multinodal API. Multinodal builds a knowledge graph from your Documents. Each Relationship in the graph carries Evidence: the verbatim quote that supports it. This client uploads Documents, waits for processing, reads the graph, and writes exports.
This guide takes you from installation to an export file on disk.
Contents
- Install
- Create an API key
- Configure the client
- Upload Documents
- Wait for processing
- Read Relationships and their Evidence
- Export the graph
- Use the async client
- Handle errors
- Retries and timeouts
- Known gaps
- Recipe notebook
- License
1. Install
The client needs Python 3.10 or later.
Its only runtime dependencies are httpx and pydantic.
pip install multinodal
Release status. Version 0.1.0 is not on PyPI yet. The owner publishes it. Until then, install from a clone of this repository:
git clone https://github.com/multinodal-ai/python-sdk.git pip install ./python-sdk
2. Create an API key
- Sign in at https://multinodal.dev.
- Open Settings → API Keys.
- Select Create new key, and enter a name.
- Select the access level.
- Read only reads Collections, Documents, the graph, exports, and usage.
- Read and write also creates, changes, and deletes Collections and Documents. It spends Page Credits.
- Copy the key. It starts with
mn_. The page shows it once.
This guide uploads Documents, so it needs a Read and write key.
A write with a Read only key fails with the forbidden error.
Processing spends Page Credits. A Document costs one Page Credit per page, at the rate of its Pricing Band. PDF and DOCX use Band A. TXT and Markdown use Band B. A failed Document costs nothing. Top up your Credit Balance in the webapp before you upload.
3. Configure the client
Set the key in the environment:
export MULTINODAL_API_KEY="mn_..."
Create the client:
from multinodal import Multinodal
mn = Multinodal()
The client reads two environment variables.
| Variable | Required | Default |
|---|---|---|
MULTINODAL_API_KEY |
Yes | None |
MULTINODAL_BASE_URL |
No | https://api.multinodal.dev |
You can pass both values as arguments instead:
mn = Multinodal(api_key="mn_...", base_url="https://api.multinodal.dev")
Do not hard-code the host.
The API host can move to a different domain.
The client follows MULTINODAL_BASE_URL, so a move needs no code change.
Check your Credit Balance before you spend it:
usage = mn.usage.me()
print(usage.balance_micros) # an int, in USD micros
print(usage.balance) # the same amount as a Decimal, in USD
Money is always an int of USD micros, or a Decimal.
It is never a float.
4. Upload Documents
Create a Collection. A Collection holds Documents and one knowledge graph. Entities are reconciled across all Documents in the same Collection.
col = mn.collections.create(name="Filings")
print(col.id) # save this id to reopen the Collection later
A Collection name has 1 to 30 characters.
Upload Documents from local paths:
batch = col.documents.upload(["a.pdf", "b.pdf"])
The accepted formats are PDF, TXT, Markdown, and DOCX.
One call accepts up to 10 Documents.
upload returns when the server accepts the Documents.
Processing then runs in the background.
To reopen a Collection later, pass its saved id:
col = mn.collection(saved_id) # a handle; it makes no network call
5. Wait for processing
documents = batch.wait_until_done(timeout=600)
for doc in documents:
print(doc.filename, doc.status)
wait_until_done returns when every Document in the batch reaches a terminal status.
| Status | Meaning | Billed |
|---|---|---|
DONE |
Processing succeeded. | Yes |
DONE_WITH_WARNINGS |
Processing succeeded with degraded extraction. | Yes |
ERROR |
Processing failed. doc.error_message holds the reason. |
No |
A Document that only partly succeeds is billed in full.
The client does not poll.
It follows the event stream (GET /api/v1/events) over server-sent events.
It opens the stream first, then reads each Document once, so it misses no event.
The server keeps events for 7 days, and the stream replays missed events in order.
A dropped connection resumes from the last event id.
The call raises WaitTimeout, a subclass of TimeoutError, when timeout seconds pass first.
Processing continues on the server.
Call wait_until_done again to keep waiting.
6. Read Relationships and their Evidence
A Relationship is a directed, typed assertion between two Entities. Each one carries Evidence: the verbatim quote from the Document text that supports it.
for rel in col.graph.relationships(type="works_at"):
print(rel.subject.name, rel.type, rel.object.name)
print(" Evidence:", rel.evidence.text)
print(" offset:", rel.evidence.offset, "verified:", rel.evidence.verified)
typefilters on one relation type. Omit it to read every Relationship.- Relation types are plain strings. The Documents produce them, so no fixed list exists.
- The iterator fetches pages as you read. It does not load the whole graph at once.
rel.evidence.verifiedisTruewhen the pipeline found the quote in the Document text. A quote that matches only after pronouns are resolved also counts as verified.rel.evidence.offsetis the character position of the quote in the Document's extracted text. It isNonewhen the quote matched only after pronoun resolution, or did not match.
Inferred Relationships are a separate type. They come from reasoning over other Relationships, not from the text. They carry no Evidence.
for inf in col.graph.inferred_relationships():
print(inf.subject.name, inf.type, inf.object.name, inf.rule, inf.hops)
Check for an empty graph before you read it:
if col.graph.is_empty():
print("Processing finished, but no Relationships were extracted.")
A Collection can finish processing with no Relationships. See Known gaps.
Every model is plain Pydantic data underneath:
col.model_dump()
7. Export the graph
col.export("cypher", to="graph.cypher")
The export streams to the file in chunks.
If the connection breaks mid-stream, the client deletes the partial file and raises IncompleteExport.
You never get a truncated file that looks complete.
| Format | Argument | Write it to |
|---|---|---|
| Cypher | "cypher" |
graph.cypher |
| JSON-LD | "jsonld" |
graph.jsonld |
| Turtle | "turtle" |
graph.ttl |
| GraphML | "graphml" |
graph.graphml |
| NetworkX Parquet | "parquet" |
graph.zip |
The parquet export is a ZIP file that holds nodes.parquet and edges.parquet.
The server builds it in memory before it sends the first byte.
A large Collection waits longer before the download starts.
You now have an export file on disk.
8. Use the async client
AsyncMultinodal has the same surface as Multinodal.
Await each call, and use async for on each iterator.
import asyncio
from multinodal import AsyncMultinodal
async def main() -> None:
amn = AsyncMultinodal()
col = await amn.collections.create(name="Filings")
batch = await col.documents.upload(["a.pdf", "b.pdf"])
await batch.wait_until_done(timeout=600)
async for rel in col.graph.relationships(type="works_at"):
print(rel.subject.name, rel.evidence.text)
await col.export("cypher", to="graph.cypher")
asyncio.run(main())
A handle from amn.collection(saved_id) holds only the id.
Call await col.load() before you read its fields, such as col.name.
A handle that create, get or update returns is already loaded.
Use AsyncMultinodal inside async code.
The sync wait_until_done can block for minutes.
Inside an event loop, that blocks every other task.
9. Handle errors
Every API error raises a subclass of MultinodalError.
The error carries the RFC 7807 problem fields: type, title, status, detail, and instance.
A validation-failed error also carries errors, one entry per invalid field.
Match on the slug, not on the full type URI.
The slug is the last segment of type, such as out-of-credits.
The slug never changes. The host in the URI can change.
from multinodal import MultinodalError, OutOfCredits
try:
batch = col.documents.upload(["a.pdf"])
except OutOfCredits:
print("Top up your Credit Balance at https://multinodal.dev.")
except MultinodalError as err:
print(err.slug, err.status, err.detail)
The client maps each slug to its own exception class.
An unknown slug raises MultinodalError itself.
| Slug | Status | What to do |
|---|---|---|
unauthorized |
401 | Set MULTINODAL_API_KEY. |
invalid-key |
401 | Check the key. Create a new one if you lost it. |
key-revoked |
401 | Create a new key. |
forbidden |
403 | Use a Read and write key for writes. |
not-found |
404 | Check the id. A key reads only its own owner's resources. |
validation-failed |
400 | Read err.errors for the invalid fields. |
invalid-cursor |
400 | Restart the iteration. |
out-of-credits |
402 | Top up your Credit Balance. |
budget-exceeded |
402 | Raise your Budget, or wait for the next period. |
credits-exhausted |
402 | Top up your Credit Balance. |
document-too-large |
413 | Split the Document. |
unsupported-file-type |
415 | Convert it to PDF, TXT, Markdown, or DOCX. |
document-not-retryable |
409 | Only a failed Document can be retried. |
rate-limit-exceeded |
429 | The client retries it for you. See below. |
events-expired |
410 | The client handles it inside wait_until_done. |
pricing-uncalibrated |
503 | Try again later. |
internal-error |
500 | Try again later. Report it if it persists. |
Each type URI opens a page that describes the error.
The API host lists every slug at /errors/.
10. Retries and timeouts
The API has no idempotency keys. So the client retries only a request that is safe to send twice.
| Method | Retried on |
|---|---|
GET, DELETE |
429, 502, 503, 504, and connection errors |
POST, PATCH |
429 and connection errors only |
A connection error means the request never reached the server. A 429 means the server refused it before doing any work. Both are safe to resend.
A timed-out upload request is not resent. The server may have received it and started processing. A second send could process and bill the Document twice. After an upload request times out, list the Collection's Documents before you upload again:
for doc in col.documents.list():
print(doc.filename, doc.status)
Backoff is exponential with full jitter.
The client honours the Retry-After header on a 429.
The client sets separate connect, read, write, and pool timeouts. You can override them per call.
11. Known gaps
These are current limits of version 0.1.0. None of them is a promise of a fix.
- Evidence does not name its source Document.
Evidence carries
text,offset, andverified. It carries no Document id. In a Collection with several Documents, you cannot trace a quote to the Document it came from.offsetlocates the quote only inside that unnamed Document. - Inverse relation types are not collapsed.
works_atandemploysstay two separate types. A filter onworks_atdoes not return assertions recorded asemploys. To read a relationship completely, query each direction by name. The measured rate of inverse pairs is low, and collapsing them would break existing queries. - A Document can finish with an empty graph.
A
DONEDocument can yield no Relationships. Checkcol.graph.is_empty(). An empty graph is not a client error. - Parquet export is buffered on the server. The other four formats stream. Parquet waits until the whole file is built.
- The relation-type vocabulary is unbounded. Types come from the Documents. Discover them from the graph you receive, not from a schema.
12. Recipe notebook
notebooks/retrieval_over_the_graph.ipynb shows retrieval over the graph.
It uses a frozen corpus of two public-domain PDFs in notebooks/corpus/.
Running it spends 8 Page Credits at the Band A rate.
The notebook is a recipe for you to run and measure yourself.
It makes no claim about accuracy or quality.
13. License
Apache License 2.0. See LICENSE.
Release files for multinodal 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| multinodal-0.1.0.tar.gz | 59.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| multinodal-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.1 kB
Release files / multinodal-0.1.0.tar.gz
| Download URL | multinodal-0.1.0.tar.gz |
|---|---|
| Size | 59.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0fd36ff4c78088c82432f8fbd6535ef0498e6d4280e3ceb4a071d39d65880867
|
|
BLAKE2b-256 checksum How to use checksums |
a84096c5d36e7262359b9f086834639873808ddec75d3f3aa1e31db2b736bdc2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Sep 25, 2026.
Transparency logRelease files / multinodal-0.1.0-py3-none-any.whl
| Download URL | multinodal-0.1.0-py3-none-any.whl |
|---|---|
| Size | 40.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0297fb81d963cec2258ca1fecd943552d82603505b9906abe784c324265fa3cb
|
|
BLAKE2b-256 checksum How to use checksums |
281dc78feaf92bc7c5d8f8eb56c2dad63109cf0a42cc5518a5e95998f35c7305
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Sep 25, 2026.
Transparency log