- Python SDK
- Install
- Persistent Python and checkpoint recovery
- Execution Memory
- Rehearse-Commit transactions
- Transactional AppSpace
- ReplayGraph Worlds
- Browser automation and screenshots
- PyProc Eyes from Python
- Proof-Carrying Motor from Python
- Cancellation and errors
- Returning to a running host
- Distribution verification
- Repository experience verification
The official pyproc-control Python package starts and controls pyproc without JavaScript application code.
The SDK has no runtime dependency and supports Python 3.10 or newer.
Every release builds three Python distributions from the same commit:
- A platform wheel for Windows x64 (
win_amd64) or Linux x64 (manylinux_2_28_x86_64, glibc 2.28 or newer) carries the host itself: the npmpyprocpackage of the same version and a checksum-pinned official Node.js runtime. It needs no Node, npm, orPATHsetup, and it installs thepyproc-controlandpyproc-mcpcommands. - The pure wheel (
py3-none-any) and the source distribution carry only the protocol client, typed values, cancellation, and attachment verification. They start thepyproc-controlcommand that the npmpyprocpackage puts onPATH, or an explicit command.
python -m pip install \
"https://github.com/eddmpython/pyproc/releases/download/v0.0.46/pyproc_control-0.0.46-py3-none-win_amd64.whl"On Linux x64 install pyproc_control-0.0.46-py3-none-manylinux_2_28_x86_64.whl from the same release. The
platform wheel is the whole product: PyProcClient.start uses its own host even when another pyproc-control
is on PATH, because that host is the exact release this client was built with. The same commit always yields
the same wheel bytes, the release's python-distributions-manifest.json lists each SHA-256, and every asset has
a GitHub build provenance attestation (gh attestation verify <file> --repo eddmpython/pyproc).
On other platforms, install the pure wheel and the npm package at the same exact version:
npm install --save-exact [email protected] python -m pip install \ "https://github.com/eddmpython/pyproc/releases/download/v0.0.46/pyproc_control-0.0.46-py3-none-any.whl"
PyPI is not an installation source yet. Use the exact-version asset URL because floating release URLs are outside the reproducible installation contract.
Create the version 1 manifest with Machine Entrance. A Python-only recipe expands to
"browser": { "enabled": false }. Run the complete doctor before use:
pyproc-mcp init --recipe pythonOnly pyproc-control doctor --config ./.pyproc/manifest.json
With the pure wheel, run the same commands through npx.
from pyprocControl import PyProcClient report = PyProcClient.doctor(".pyproc/manifest.json") print(report["next"]["firstResult"])
doctor() returns the complete effect-free report, including a structured next.firstResult that maps the same
machine.run input to shell, JavaScript, Python, and MCP. It returns representable blocking reports with
ok == False, so a caller can inspect blocking before deciding whether to start. check() remains the lighter
startup-configuration compatibility check.
Without a platform wheel, the npm bin directory must be on PATH. An embedded application can always pass an
explicit command list such as command=[nodePath, controlScriptPath].
from pyprocControl import PyProcClient with PyProcClient.start(".pyproc/manifest.json") as client: client.runPython("prepared = [10, 20, 30]") checkpoint = client.saveCheckpoint() try: client.runPython("prepared.append(999)\nraise ValueError('failed attempt')") except Exception: client.restoreCheckpoint(checkpoint.output["index"]) result = client.runPython("prepared") print(result.output["value"])
ControlResult contains terminal="completed", output, outcome, and an ordered tuple of verified
attachments. ControlError.terminal is rejected, partial, outcomeUnknown, or cancelled while preserving
the protocol code, outcome, and retryability. Machine state persists for the life of the client. reset()
restores the prepared boot checkpoint.
Add --execution-memory-root /absolute/private/pyproc-memory to pyproc-mcp init. The Python client then uses
the same immutable revisions as JavaScript and MCP:
project = { "workspaceId": "workspace:forecast", "commit": "exact-commit", "treeSha256": "sha256:...", "diffSha256": "sha256:...", "untracked": False, } created = client.createExecutionSession("session:forecast", project) opened = client.openExecutionSession("session:forecast") assert created.output["contentSha256"] == opened.output["contentSha256"]
checkpointExecutionSession, completeExecutionSession, listExecutionSessions,
inspectExecutionSession, exportExecutionHandoff, and importExecutionHandoff map directly to the Control
operations. The registry persists state, not the client object. See Execution Memory.
An effect-enabled profile exposes the same transaction lifecycle as JavaScript and MCP:
prepared = client.prepareEffectTransaction(intentInput) rehearsed = client.rehearseEffectTransaction( intentInput["transactionId"], prepared.output["transaction"]["contentSha256"], {"mode": "computed", "code": "6 * 7", "expectedValue": "42"}, ) approved = client.approveEffectTransaction( intentInput["transactionId"], rehearsed.output["contentSha256"], signedGrant) terminal = client.commitEffectTransaction( intentInput["transactionId"], approved.output["contentSha256"]) sealed = client.sealEffectTransaction( intentInput["transactionId"], terminal.output["contentSha256"], evidencePackDir)
inspectEffectTransaction and listEffectTransactions are read-only lifecycle queries. The Python package
does not mint approval authority; pass a canonical grant produced by the separately trusted issuer. Recovery
from a durable sending state never repeats the provider call. See Rehearse-Commit.
An AppSpace-enabled FrameSpace profile exposes attachApp, checkpointApp, branchApp, restoreApp,
adoptApp, inspectApp, listAppPairs, stageAppEffect, and finalizeAppEffect. These methods send the same
canonical app.* operations as JavaScript and MCP. The installed AppSpace gate reopens the durable list through
all three clients and requires the same active pair digest.
The Python client does not implement an independent app state machine. Live pair restore still requires the originating control process and its in-process Machine checkpoints. See Transactional AppSpace.
A ReplayGraph-enabled profile exposes importReplayGraphRecording, createReplayGraphAppWorld,
captureReplayGraphAppBranch, openReplayWorld, inspectReplayWorld, listReplayWorldEdges,
traverseReplayWorld, checkpointReplayWorld, restoreReplayWorld, evaluateReplayWorld,
inspectReplayWorldCoverage, and listReplayGraphs. These methods send the same canonical world.*
operations as JavaScript and MCP.
The Python client owns no independent graph state. Durable revisions live under the shared Execution Memory root; open cursor references and checkpoints remain in the originating Control process. The installed product gate imports and reopens the same root through Python, JavaScript, and MCP without calling a live provider. See ReplayGraph Worlds.
Browser operations appear only when the manifest grants automation authority. Risk is fixed by the product catalog and must be acknowledged on each effect:
from pyprocControl import ActionConvergenceReceipt with PyProcClient.start(".pyproc/manifest.json") as client: opened = client.openTarget( "https://example.test", expectedRisk="externalEffect", waitUntil="load", ) attached = client.attachSession(opened.output["targetRef"]) captured = client.act(attached.output, [ {"kind": "screenshot", "format": "png", "expectedRisk": "read"} ]) png = captured.attachments[0] assert png.mimeType == "image/png" with open("capture.png", "wb") as file: file.write(png.bytes) client.detachSession(attached.output) client.closeTarget(opened.output["targetRef"])
The client does not expose attachment bytes until chunk order, byte length, MIME type, and SHA-256 match the terminal descriptor. The JSON output retains the opaque artifact reference for bounded reads and deletion.
Large legacy semantic inventories use the same bounded continuation contract:
pages = [] observed = client.observe(attached.output, { "expectedRisk": "read", "mode": "all", "maxNodes": 500}) while True: page = observed.output.get("result", observed.output) pages.append(page) if page["inventory"]["complete"]: break observed = client.observe(attached.output, { "expectedRisk": "read", "continuationRef": page["continuationRef"]}) nodes = [node for page in pages for node in page["nodes"]] assert len(nodes) == pages[-1]["inventory"]["total"]
Pass no first-page options with continuationRef. Every page must retain one receiptSha256, and the final
canonical sorted-key JSON digest must equal nodesSha256. A document replacement raises ControlError with
code AUTOMATION_OBSERVATION_CONTINUATION_STALE; do not mark the collected prefix complete.
client.perception(sessionRef) binds provider-neutral APX graph and situation representations to one attached
session. It uses the same
automation.observe and automation.act operations as MCP and native JavaScript clients, so Python does not
gain a second automation meaning or a raw CDP entrance.
with PyProcClient.start(".pyproc/manifest.json") as client: opened = client.openTarget( "https://example.test", expectedRisk="externalEffect", waitUntil="load", ) attached = client.attachSession(opened.output["targetRef"]) eyes = client.perception(attached.output) situation = eyes.situate({"requirements": [{ "requirementRef": "requirement:save", "select": {"role": "button", "name": "Save", "actionable": True}, "need": ["fact", "affordance"], "cardinality": "one", }]}) save = situation.requirement("requirement:save").oneAffordance("click") result = eyes.actAffordance( save, intent="Save the document", verify={"entityAppeared": {"role": "status", "nameContains": "Saved"}}, ) assert result.output["actions"][0]["result"]["evidence"]["verification"]["state"] == "confirmed" receipt = ActionConvergenceReceipt.fromMapping(result.output["actions"][0]["convergence"]) assert receipt.maxAttempts == 2 and receipt.effectRetries == 0
SituationResult exposes immutable requirements, facts, affordances, and unknowns. Only an authorized
SituationAffordance can be passed to actAffordance; page-reported affordances remain content. The older
PerceptionEntity exposes entityRef, locatorRef, kind, role, name, and actionable properties.
query(...).one() checks APX query.matched and rejects zero or multiple matches even when the byte budget
returns only one candidate. whatChanged(observationRef) requests
a delta, while explainActionability(entityRef) narrows the graph to semantic, geometry, and interaction
facts. Observation identity never grants action authority, and locator capability remains short-lived.
Each requirement exposes candidateEvidence from the pre-projection candidate universe. Complete inventories
carry an exact count and digest; incomplete inventories carry only a lower bound and authorize no effect. Verified
action output includes send-boundary timing, independent input-release evidence, and observationCoverage for
focused entities and monotonic event windows. An incomplete window never proves that an event was absent.
Use ActionConvergenceReceipt.fromMapping(...) for a successful action terminal or
ControlError.details["convergence"]. Native CDP uses output["actions"][0]; FrameSpace uses
output["results"][0]. The receipt fixes two candidate attempts, one reobservation, zero effect retries, and a
30000 ms first-effect deadline while reporting the actual effect count and timing.
Native CDP supports verified pixel-on-demand attachments. FrameSpace supports APX through temporal level L3 but rejects visual inference. See the APX 1.0 product contract for the graph and evidence contract.
openMotorTask() binds target, session, Situation, artifact, and cleanup ownership to one task, with the same meaning
as the JavaScript task, and shares the same durable Motor records and receipt digests as JavaScript and MCP:
from pyprocControl import (ActuationIntent, MotorAuthority, MotorPolicy, MotorTarget, actuationDigest) with client.openMotorTask(url="https://app.example/work", waitUntil="load") as task: observed = task.situate({"requirements": [{ "requirementRef": "requirement:save", "select": {"role": "button", "name": "Save", "actionable": True}, "need": ["fact", "affordance"], "cardinality": "one", }]}) if not task.diagnoseAmbiguity(observed, "requirement:save").canExecute: raise RuntimeError("caller refinement is required") situation = observed.situation affordance = next(entry for entry in situation["affordances"] if entry["kind"] == "authorized" and entry["action"] == "click") motor = task.execute(observed, "requirement:save", ActuationIntent( intent="activate", target=MotorTarget(spaceRef=spaceRef, entityRef=situation["requirements"][0]["entityRefs"][0], worldRef=situation["worldRef"], surfaceEpoch=f"document:{situation['documentEpoch']}"), desired={"activated": True}, authority=MotorAuthority(actionCapabilityRef=affordance["capabilityRef"]), policy=MotorPolicy(allowedActuatorKinds=("browserInput",)), expectedTransition={"entityAppeared": {"role": "status", "name": "saved"}}, )) receipt = dict(motor.output["receipt"]) assert actuationDigest({k: v for k, v in receipt.items() if k != "receiptSha256"}) == receipt["receiptSha256"]
The task executes only a Situation it observed whose requirement settled on one complete target; otherwise
execute() raises TypeError, and diagnoseAmbiguity() names the caller-owned predicates that would refine it.
Leaving the with block (or close()) detaches the session, deletes the artifacts the task did not retain, and
closes a target it opened; it returns a MotorTaskCleanup and never retries an effect. A closed task raises
RuntimeError("Motor task session is closed"). executeMotor() still takes an intent mapping directly.
ActuationIntent carries the exact Situation world, entity, surface epoch, action capability, desired final state,
expected transition, and actuator allowlist. canonicalActuationJson() and actuationDigest() produce the Control
host's canonical JSON and digest byte for byte, so a receipt can be checked locally. Python does not receive raw
coordinates or provider handles.
Windows physical input additionally requires acquireMotorControl() and a one-shot control lease. Use
revokeMotorControl() before execution when the surrounding product cancels the task. See
the Motor guide.
requestAsync returns a ControlRequest:
from pyprocControl import ControlError request = client.requestAsync("machine.run", { "code": "import time\ntime.sleep(30)" }) request.cancel("deadline reached") try: request.result() except ControlError as error: print(error.code, error.outcome, error.retryable)
Every synchronous timeout= is an effect-safe request deadline. When the local wait expires, the client sends
one protocol cancel and waits for the canonical terminal. A queued request ends as notSent; a delivered effect
normally ends as non-retryable outcomeUnknown. If the server does not settle after cancellation, the client
closes the connection and raises non-retryable CONTROL_TIMEOUT with outcomeUnknown.
If the transport ends while a request is pending, or a request frame write cannot prove full delivery, the
SDK raises non-retryable CONTROL_CONNECTION_LOST with outcomeUnknown. A request rejected before writing
because the connection is already known to be unavailable is notSent. Raw pipe and EOF errors do not escape
through the request surface.
Cancellation before delivery is notSent. After delivery, the product returns outcomeUnknown unless the
provider proves a narrower boundary. Never retry an applied or outcomeUnknown effect automatically.
Python restore does not undo browser, storage, network, download, popup, or other external effects.
ControlError exposes stable code, outcome, retryable, details, and fatal fields. Protocol framing
or digest violations fail the whole client and reject every outstanding request.
A host ends with the process that started it, and its browser with it. Start it with reconnectFile when the
browser sessions must outlive that process:
client = PyProcClient.start(".pyproc/manifest.json", reconnectFile="state/reconnect.json", reconnectSeconds=120) opened = client.openTarget("https://example.test", expectedRisk="externalEffect", waitUntil="load") sessionRef = client.attachSession(opened.output["targetRef"]).output # Store opened.output["targetRef"] and sessionRef where the next process can read them.
If this process dies while a session is attached, the host keeps the browser and waits reconnectSeconds
(60 by default, at most 1800) for a client. Another process returns to it and uses the stored references:
client = PyProcClient.reconnect("state/reconnect.json") client.observe(sessionRef, {"expectedRisk": "read"})
The waiting host does not listen. reconnect listens on a loopback port, announces it beside the file, and the
host connects to it within startupTimeout seconds, so both processes need to read and write the directory of
the file. The Control Protocol reference has the exchange.
reconnect raises FileNotFoundError or ConnectionError when no host waits there or none comes in time, and
ValueError when the file is not an endpoint; open a new host in that case. The returned client has no
process; hostPid is the process ID of the host and connected turns false once the connection is closed or
lost. Requests that were in flight when the first client was lost ended with it, so treat an effect it had sent
as outcomeUnknown.
close() ends the host at once only when no session is attached, so detach the sessions first. With a session
still attached, the client that started the host stops it after the close timeout, and a host left by a client
that had returned to it waits until its time runs out. The file and the announcement beside it hold secret
tokens: keep them in a directory only the owning user can read and write, and never log them. The wire contract
is in
Control Protocol.
npm run test:python-sdk builds both the wheel and source distribution with pinned build tools, installs
each into a separate clean virtual environment, and runs these installed-package checks:
-
the platform wheel for the running host, installed alone into a virtual environment whose
PATHhas no Node, starts its bundled host throughpyproc-controlandpyproc-mcp init, runs Python, and opens and closes a Machine session and a browser-only session; -
strict codec positive and negative fixtures;
-
PATH-based product preflight and handshake;
-
persistent Python and checkpoint restore;
-
post-send cancellation and request ID single-use;
-
permission denial before browser effect;
-
APX query through both Native CDP and FrameSpace;
-
real browser open, attach, PNG capture, SHA-256 verification, artifact deletion, and detach.
-
provider-neutral
inspectSpace().output["resources"]convergence after target close. -
real Machine image capture, immutable Execution Memory publication, reopen, and list through the installed Python wheel.
-
signed exact-intent approval, one live HTTP effect, no resend, and verified EffectReceipt sealing through the installed Python wheel.
Chrome on Ubuntu and Edge on Windows run the same gate in CI. npm run package:python -- --tree <commit> --out <empty-directory> builds the release set, and the python-distribution-reproducibility workflow builds it on
Ubuntu and Windows and requires byte-identical platform wheels.
The Python facade uses the same three Control operations as JavaScript and MCP:
repository = { "commit": "exact-commit-id", "treeSha256": "sha256:...", "diffSha256": "sha256:...", "untracked": False, } audited = client.auditExperience( "qa/eyes", repositoryRoot=".", outputDir=".pyproc/evidence/current", environmentId="desktop", repository=repository, motorJourneys=[{ "receiptSha256": receipt_sha256, "scenarioId": "save-document", "checkpointId": "post-save", }], ) client.replayEvidencePack(".pyproc/evidence/current") client.verifyExperience(".pyproc/evidence/reference", ".pyproc/evidence/current")
All filesystem inputs are resolved to absolute paths by the client except outputDir, which deliberately remains
relative to repositoryRoot. auditExperience requires browser authority. Verify and replay are effect-free and
remain available in browser-disabled profiles. See experience verification for the
repository contract and Evidence Pack format.