Python Client 202: Scripting Your First Digital Thread
In Web app 101 you used the browser to register a spreadsheet, run an extraction, add a new version, extract again, and compare what changed.
Here you will do the same flow with a short Python script: one run registers the file, runs two extractions, saves the extracted named_cells from each pass, and produces a comparison — same files, same revisions you see in the web app, but now the extracted data is in your hands as JSON to apply your own logic.
Run the script end to end once (it only takes a minute or two to finish). Then use Understand the script for a short walkthrough of each part and how it maps to the Istari Digital Platform.
By the end you will know how to:
- Connect to the Istari Digital Platform from Python (acting as your user)
- Register a file, run a job, and wait for it to finish
- List and download extracted resources
- Add a new revision and run extraction again
- Apply your own logic to the extracted data while staying anchored to the file and revisions recorded by the Istari Digital Platform
Time: ~15 minutes.
Prerequisites
- An Istari Digital Platform account. If you don't have one yet, follow the Sign-up Guide.
- Work through Web app 101 first so the web app ideas (model, job, resources, versions) feel familiar.
- A Key for the Istari Digital Platform instance you are using. The same
istari.envas Python Client 201 — Your first System works here. Details: Developer Settings — Keys. Personal Access Tokens are deprecated (July 2026). - An agent with the Open Spreadsheet integration and access to run
@istari:extract— your admin can confirm; see Manage Tool Access.
Sample files (same as Web app 101). Save both in a folder, for example ~/uas-tutorial/:
- Group3-UAS-Requirements.xlsx — baseline
- Group3-UAS-Requirements-v2.xlsx — after a design change
Set up your environment
Install the client
We use uv so the same commands work on every machine:
curl -LsSf https://astral.sh/uv/install.sh | sh # if you do not have uv yet
mkdir -p ~/uas-tutorial && cd ~/uas-tutorial
# Pin the venv to a Python version supported by the client.
# If 3.12 is not already on your machine, uv downloads a managed CPython build for you.
uv venv --python 3.12
source .venv/bin/activate # macOS / Linux
# .venv\Scripts\Activate.ps1 # Windows (PowerShell) — uncomment instead of the line above
uv pip install "istari-digital-client>=13.2.1"
--python?uv venv --python 3.12 tells uv to use Python 3.12 specifically; if it is not on your machine, uv downloads a managed CPython build and uses that — no pyenv or brew install python needed.
If you'd rather use python -m venv or pyenv directly, make sure your interpreter matches the client's supported versions.
Beta: the
Istariclient this tutorial uses is marked Beta inistari-digital-client, so its method names can change between releases. This tutorial was tested with 13.2.1 through 13.3.3; if a later release changes a method name, install one of those versions.
Point the script at your Istari Digital Platform
Create istari.env in ~/uas-tutorial/ (reuse the file from Python Client 201 if you already have one):
ISTARI_DIGITAL_API_URL=<API URL from Endpoints on Developer Settings>
ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE=/absolute/path/to/the-key-file-you-downloaded.json
ISTARI_DIGITAL_IDENTITY_SERVICE_ENABLED=true
These are the names Configuration reads on its own, so your own scripts can call Configuration() with no arguments and let istari.env decide the credential:
ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILEtakes theISTARI_CLIENT_prefix. It is the only name read for the key, and there is no unprefixed fallback.tutorial202_2026_10.pyalso accepts the older unprefixedIDENTITY_SERVICE_SECRET_FILEif you already have anistari.envfrom an earlier tutorial.- With the key path and
ISTARI_DIGITAL_API_URLset, Key authentication turns on even whenISTARI_DIGITAL_IDENTITY_SERVICE_ENABLEDis absent. The sample sets it totrueso the choice is explicit. Set it tofalseto keep a personal access token while a key file is also present. A token on its own usesISTARI_REGISTRY_URLandISTARI_REGISTRY_AUTH_TOKEN.
In the Istari Digital web app, open avatar → Developer (example: https://demo.istari.app/settings?tab=developer-settings). Click Generate Key, then Download credentials — the key is shown once.
Keep istari.env and the key file on disk. Do not put a key or token on the command line, in a prompt, or in a chat log.
Run the script
- In the folder that contains the two
.xlsxfiles, save tutorial202_2026_10.py there (easiest). The full script is also reproduced below in Full script if you prefer to read it inline or paste into a new file. - The script uses
assertto catch a few common mistakes up front: the two.xlsxfiles must be in the current folder, andistari.env(or the environment) must name a Key. If Python raisesAssertionError, read the message—it points to what to fix. - Run:
python tutorial202_2026_10.py
What you should see
In the terminal: Resource and revision IDs, two jobs that reach Completed, two lists of resources, then writes of named_cells_rev1.json and named_cells_rev2.json,
followed by a summary (file name, resource id, from / to revision ids and times) and a Change detail list of the named cells that changed (name: before → after)
— output tied to the same source of truth as the Istari Digital Platform, not just anonymous JSON.
A trimmed example of what scrolls past (your IDs, timestamps, and values will differ):
Connected.
Resource registered: Group3-UAS-Requirements.xlsx
Resource ID: 1e59b631-bf46-4945-ad4e-271803add9d2
Revision ID: c4a5b113-72b0-4666-b351-0b1cdfea4f61
Job: 8e092739-3712-4434-8f3c-659f99ebfc00
status: Pending
status: Running
status: Completed
First extraction done.
Resources produced by job 1 (6 items):
- named_cells.json (resource=4ab2… rev=9f01…)
- worksheet_data.json (resource=5cd3… rev=a112…)
- workbook.html (resource=6ef4… rev=b223…)
- …workbook.pdf, workbook.xlsx, run logs (each with their own resource/rev ids)
Saved …/named_cells_rev1.json (rev 9f01…)
Second file uploaded (new revision).
Resource ID: 1e59b631-bf46-4945-ad4e-271803add9d2 (same as before)
Revision ID: 279cd9f6-cbc8-4dc4-8205-07a64df4e9e1
Job: b98c655d-a080-4925-ab4d-b3b4b61d00e2
status: Claimed
status: Running
status: Completed
Second extraction done.
Resources produced by job 2 (6 items):
- named_cells.json (resource=4ab2… rev=c334…) ← same resource id as run 1, new revision id
- worksheet_data.json (resource=5cd3… rev=d445…) ← same resource id, new revision
- …same kinds as above; in each row resource= matches run 1 and rev= is new
Saved …/named_cells_rev2.json (rev c334…)
--- Summary (resource and revisions from the Istari Digital Platform) ---
Named parameters (named_cells) for 'Group3-UAS-Requirements.xlsx' — resource 1e59b631-bf46-4945-ad4e-271803add9d2
from: revision c4a5b113-72b0-4666-b351-0b1cdfea4f61 · 2026-04-24T00:27:43+00:00 · user 80ddca7e-…
to: revision 279cd9f6-cbc8-4dc4-8205-07a64df4e9e1 · 2026-04-24T00:28:08+00:00 · user 80ddca7e-…
Change detail (8 named cell(s) changed):
- SubTitle: …Rev A… → …Rev B…
- control_signal_value: 60000.0 → 80000.0
- cruise_speed_value: 100.0 → 110.0
- max_weight_value: 275.0 → 310.0
- position_accuracy_value: 2.5 → 1.5
- range_value: 1000.0 → 1200.0
- temperature_value: -10 to +45 → -20 to +50
- video_tx_value: 50000.0 → 80000.0
The two Resource ID lines are identical while the Revision IDs differ — the same logical file moving through two versions, exactly as you'd see on the Versions tab in the web app.
The same pattern shows up for each output: in the per‑job listings, the resource= of named_cells.json (and every other output name) matches across run 1 and run 2, while the rev= is new in run 2 — proof that the second extraction added a new revision to the same output resource, not a brand‑new one.
The Change detail list is what a developer ultimately wants: just the named cells whose values moved between those two revisions.
On disk (next to the script and spreadsheets):
| File | Role |
|---|---|
named_cells_rev1.json | Snapshot of named cells after the first revision’s extraction |
named_cells_rev2.json | Snapshot after the second revision’s extraction |
Verify in the web app
The script and the web app are not parallel demos: they act on the same files. Sign in to the same org as your Key, then check:
- Resources — Open the registered Resource (e.g.
Group3-UAS-Requirements) and match Resource ID and revision IDs to the script output. - Versions — Two revisions (baseline and updated spreadsheet), as in Web app 101.
- Activity or Jobs — Two Completed extractions for
open_spreadsheet/@istari:extract. - Resources — Extraction outputs including
named_cells.json, as in Web app 101 — Step 4. - Compare versions — Step 7 in Web app 101: use the in-app File Comparison; your terminal diff is the same story using the saved JSON files.
Note: Each list shows only what that job produced. A job's outputs are tied to the revision it ran on, so the script reads that revision's produces relationships rather than everything that ever landed on the resource. The script also saves a copy of the first named_cells to disk before the new spreadsheet is added, so the comparison stays valid even after the second extraction adds a new revision of the same output.
Understand the script
tutorial202_2026_10.py first asserts that the sample spreadsheets and credentials are present.
The numbered steps then follow the same story as Web app 101:
connect → register → first extraction → save a snapshot of named_cells → add a new file revision → second extraction → save named_cells again → compare.
Connect
Configuration takes digital_api_url, identity_service_secret_file, and identity_service_enabled=True.
The script loads those from istari.env in the current folder (or from the environment), so the Key file stays on disk and nothing secret lives in the code.
Istari(configuration) is the client for everyday work: its resources manager handles files and their revisions, and its jobs manager runs and monitors jobs.
See Python Client 201 for a first System in one command, or SDK setup for defaults and other patterns.
A Key is issued to a user: the client acts on your behalf—the same as when you are signed in—so register, list, and job calls use your authorization to read files, run tools and functions, and work only where your account is allowed. If something is denied in the API, the usual causes are the same as in the web app. See Sharing and access for how access to files and actions is decided.
Check the connection
client.resources.list(size=1).first() is one small authenticated read.
It confirms the Istari Digital Platform is reachable and your Key is accepted, and the script stops with a pointer to the credential help if it is not.
From client 13.3.0, client.readiness() also reports the platform's own dependency checks.
Register the resource and run the first extraction
client.resources.create(path, "model")uploads the spreadsheet and registers it as a resource with a stableresource_id."model"is the resource type, so the web app can run tools on it.client.jobs.create(resource_id=..., function=..., tool_name=...)queues@istari:extractforopen_spreadsheetand returns immediately with a job id and an initial status.wait_for_job(client, job)is a tiny helper defined at the top of the script: it callsclient.jobs.get(job.id)every 5 seconds, printsjob.status_nameon each loop, and returns the refreshed job once it reachesCompleted. It raises if the job ends inFailedorCanceled, and it retries server errors and dropped connections itself, because the client does not retry them. Depending on how fast the agent runs you'll see one or several of the states listed in theJobStatusNamereference. If you do not need the printing,job.poll()blocks until the job is done and returns the final status.
The script then calls outputs_of(client, resource.file_revision_id) once. A job's outputs are tied to the revision it ran on by produces relationships, so the helper lists that revision's relationships, keeps the ones that start at it and do not end at a job, and fetches each written revision with client.resources.revisions.get(resource_id, revision_id).
That is the exact revision the agent wrote — the same resource_id and revision id you'd see in the web app — so we can print its name and ids and capture the one named named_cells.json.
Its bytes are saved to named_cells_rev1.json.
Add a new revision and run the second extraction
client.resources.revisions.create(resource_id, path) adds a new revision to the same resource — same id, new revision id.
A second jobs.create runs, wait_for_job polls until it completes, and the script reads the new outputs the same way from the new revision, writing the second named_cells output to named_cells_rev2.json.
Compare
As in Web app 101 — Compare versions, the goal is to see what changed between two revisions.
The extracted named_cells is JSON in a vendor‑neutral shape, so you are free to apply whatever custom logic fits the question — here a small dictionary name → value per revision, then a flat name: before → after line for any name where the value differs.
The same JSON could feed a chart, a filter on specific named ranges, or a short narrative drafted by an LLM.
The Summary block above the table prints the file name, resource id, and the from and to revision ids (plus time and uploader). Whether the change description is written by code or by a model, it stays grounded in the immutable source of truth the Istari Digital Platform already recorded: the file and the specific revisions it came from.
What you learned
The same actions you took in the web app in Web app 101, now from Python — on the same files, with the same model and revision ids.
The calls above come from the Istari client's resources and jobs managers. Older scripts use the flat Client methods add_model, update_model, add_job, get_job, and get_artifact; those are deprecated, and the table shows what replaces each one here.
| Concept | Web app 101 | Python (this tutorial) |
|---|---|---|
| Register a file | Drag and drop | client.resources.create(path, "model") |
| Run an extraction | Create job in web app | client.jobs.create(resource_id=..., function=...) then poll with client.jobs.get(job.id) |
| Monitor a job | Activity | client.jobs.get(job.id).status_name in a loop |
| See what a job produced | Resources tab on a Job | client.resources.relationships.list(revision_id) → client.resources.revisions.get(resource_id, revision_id) |
| Download a resource | Click Download | revision.read_bytes() → file on disk |
| Upload a new version | Add version | client.resources.revisions.create(resource_id, path) |
| Custom logic on extracted data | Compare versions | Any Python you like (name: before → after list here; could be a chart, filter, LLM narrative…) on the JSON, anchored to file / model / revision ids in the Istari Digital Platform |
Full script
The complete tutorial202_2026_10.py for reference. You can also download it.
"""Tutorial 202: register a spreadsheet, extract data twice, compare named_cells JSON locally."""
import json
import os
import sys
import time
from pathlib import Path
from istari_digital_client.sdk import (
APIConnectionError,
Configuration,
InternalServerError,
Istari,
IstariError,
)
from urllib3.exceptions import MaxRetryError
CREDENTIALS_HELP = (
"Put ISTARI_DIGITAL_API_URL and ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE in istari.env "
"(or the environment). Developer Settings → Keys: copy the API URL and the path "
"to the downloaded key file — not the key contents. See Python Client 201 or 202."
)
# The name istari-digital-client itself reads for the Key. The unprefixed
# IDENTITY_SERVICE_SECRET_FILE is still accepted so older istari.env files keep working.
SECRET_FILE_NAMES = ("ISTARI_CLIENT_IDENTITY_SERVICE_SECRET_FILE", "IDENTITY_SERVICE_SECRET_FILE")
def _env(*names: str) -> str:
for n in names:
v = (os.environ.get(n) or "").strip()
if v:
return v
return ""
def _load_istari_env(path: str = "istari.env") -> None:
"""Load KEY=VALUE lines without overwriting variables already set in the shell."""
p = Path(path)
if not p.is_file():
return
for line in p.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip().strip("'").strip('"'))
def _on_uncaught(exc_type, exc, tb):
if issubclass(exc_type, (APIConnectionError, MaxRetryError)):
print("\nCannot reach the Istari Digital Platform.")
print(CREDENTIALS_HELP)
sys.exit(1)
sys.__excepthook__(exc_type, exc, tb)
sys.excepthook = _on_uncaught
def wait_for_job(client, job, interval=5):
"""Poll a job until it reaches a terminal state, printing the status each loop.
Returns the refreshed Job; raises if it ends in Failed or Canceled.
Server errors (5xx) and dropped connections are retried silently; the client does not retry them."""
while True:
try:
job = client.jobs.get(job.id)
except (InternalServerError, APIConnectionError):
time.sleep(interval)
continue
label = job.status_name
print(f" status: {label}")
if label == "Completed":
return job
if label in ("Failed", "Canceled"):
raise RuntimeError(f"Job {job.id} ended in state {label}.")
time.sleep(interval)
def outputs_of(client, revision_id):
"""The revisions that jobs run on `revision_id` wrote, as (resource_id, revision) pairs.
Each job output is tied to the revision it ran on by a `produces` relationship."""
outputs = []
for edge in client.resources.relationships.list(revision_id):
written = edge.right_revision
if (
edge.relationship_type_name != "produces"
or edge.left_revision.file_revision_id != revision_id
or written.owning_entity_type == "job"
):
continue
resource_id = written.owning_entity_id
outputs.append((resource_id, client.resources.revisions.get(resource_id, written.file_revision_id)))
return outputs
baseline_path = Path("Group3-UAS-Requirements.xlsx")
revised_path = Path("Group3-UAS-Requirements-v2.xlsx")
assert baseline_path.is_file(), (
f"Expected {baseline_path.name!r} in {str(Path.cwd())!r}. "
"Download the sample from the Python Client 202 tutorial, save it next to this script, then run again."
)
assert revised_path.is_file(), (
f"Expected {revised_path.name!r} in {str(Path.cwd())!r}. "
"Download the v2 sample from the same tutorial, same folder, then run again."
)
_load_istari_env()
api_url = _env("ISTARI_DIGITAL_API_URL")
secret_file = _env(*SECRET_FILE_NAMES)
if api_url and secret_file:
assert Path(secret_file).is_file(), (
f"{SECRET_FILE_NAMES[0]} points at {secret_file}, which does not exist. "
"Use the absolute path of the key file you downloaded from Developer Settings."
)
configuration = Configuration(
digital_api_url=api_url,
identity_service_secret_file=secret_file,
identity_service_enabled=True,
)
else:
registry_url = _env("ISTARI_DIGITAL_REGISTRY_URL", "ISTARI_REGISTRY_URL")
token = _env(
"ISTARI_DIGITAL_REGISTRY_AUTH_TOKEN",
"ISTARI_REGISTRY_AUTH_TOKEN",
"ISTARI_PERSONAL_ACCESS_TOKEN",
)
assert api_url or registry_url, CREDENTIALS_HELP
assert secret_file or token, CREDENTIALS_HELP
configuration = Configuration(
registry_url=registry_url,
registry_auth_token=token,
)
# 1. Connect — Key (current) or PAT (deprecated July 2026)
client = Istari(configuration)
# 2. Connection check — one small authenticated read confirms the Istari Digital Platform
# is reachable and your credential is accepted.
try:
client.resources.list(size=1).first()
except (IstariError, MaxRetryError) as e:
print(f"\nCannot reach the Istari Digital Platform ({type(e).__name__}).")
print(CREDENTIALS_HELP)
sys.exit(1)
print("Connected.")
# 3. Register the first spreadsheet (same as uploading on Resources)
resource = client.resources.create(baseline_path, "model")
print(f"Resource registered: {resource.name}")
print(f" Resource ID: {resource.resource_id}")
print(f" Revision ID: {resource.file_revision_id}")
# 4. First extraction — Open Spreadsheet → @istari:extract
job1 = client.jobs.create(
resource_id=resource.resource_id,
function="@istari:extract",
tool_name="open_spreadsheet",
)
print(f"\nJob: {job1.id}")
job1 = wait_for_job(client, job1)
print("First extraction done.")
# 5. Inspect what job 1 produced and capture the named_cells revision.
# Outputs hang off the revision the job ran on, so this list is job 1's alone.
outputs_1 = outputs_of(client, resource.file_revision_id)
print(f"\nResources produced by job 1 ({len(outputs_1)} items):")
named_cells_rev1 = None
for resource_id, rev in outputs_1:
print(f" - {rev.name} (resource={resource_id} rev={rev.id})")
if rev.name and "named_cells" in rev.name:
named_cells_rev1 = rev
if named_cells_rev1 is None:
raise RuntimeError("No named_cells.json was produced by the first extraction job.")
path_named_cells_1 = Path("named_cells_rev1.json")
path_named_cells_1.write_bytes(named_cells_rev1.read_bytes())
print(f"\nSaved {path_named_cells_1.resolve()} (rev {named_cells_rev1.id})")
# 6. Add the second spreadsheet as a new revision of the same resource
new_rev = client.resources.revisions.create(resource.resource_id, revised_path)
print("\nSecond file uploaded (new revision).")
print(f" Resource ID: {resource.resource_id} (same as before)")
print(f" Revision ID: {new_rev.id}")
# 7. Second extraction on the current revision
job2 = client.jobs.create(
resource_id=resource.resource_id,
function="@istari:extract",
tool_name="open_spreadsheet",
)
print(f"\nJob: {job2.id}")
job2 = wait_for_job(client, job2)
print("Second extraction done.")
# 8. Same pattern for job 2, on the new revision
outputs_2 = outputs_of(client, new_rev.id)
print(f"\nResources produced by job 2 ({len(outputs_2)} items):")
named_cells_rev2 = None
for resource_id, rev in outputs_2:
print(f" - {rev.name} (resource={resource_id} rev={rev.id})")
if rev.name and "named_cells" in rev.name:
named_cells_rev2 = rev
if named_cells_rev2 is None:
raise RuntimeError("No named_cells.json was produced by the second extraction job.")
path_named_cells_2 = Path("named_cells_rev2.json")
path_named_cells_2.write_bytes(named_cells_rev2.read_bytes())
print(f"\nSaved {path_named_cells_2.resolve()} (rev {named_cells_rev2.id})")
# 9. Compare: list named-cell value changes between the two extractions
cells_1 = {c["name"]: c.get("value") for c in json.loads(path_named_cells_1.read_text(encoding="utf-8"))}
cells_2 = {c["name"]: c.get("value") for c in json.loads(path_named_cells_2.read_text(encoding="utf-8"))}
changes = sorted(
(name, cells_1.get(name), cells_2.get(name))
for name in set(cells_1) | set(cells_2)
if cells_1.get(name) != cells_2.get(name)
)
print("\n--- Summary (resource and revisions from the Istari Digital Platform) ---\n")
print(f"Named parameters (named_cells) for {resource.name!r} — resource {resource.resource_id}")
print(f" from: revision {resource.file_revision_id} · {resource.created.isoformat(timespec='seconds')} · user {resource.created_by_id}")
print(f" to: revision {new_rev.id} · {new_rev.created.isoformat(timespec='seconds')} · user {new_rev.created_by_id}")
print(f"\nChange detail ({len(changes)} named cell(s) changed):")
if not changes:
print(" (no value changes)")
for name, before, after in changes:
print(f" - {name}: {before} → {after}")
What’s next
- Pipelines — Sequential pipeline
- Parameter studies — Parameter study
- API reference — Python client