Metadata-Version: 2.5
Name: velophex
Version: 0.5.3
Summary: VeloPhex Python SDK: author, run, pack and publish Python automations for the VeloPhex platform.
Project-URL: Homepage, https://velophex.com
Author: VeloPhex
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: ==3.12.*
Requires-Dist: httpx<0.29,>=0.27
Requires-Dist: pydantic<3,>=2.9
Requires-Dist: typer<0.16,>=0.12
Provides-Extra: agents
Requires-Dist: jsonschema<5,>=4.23; extra == 'agents'
Requires-Dist: langchain-core<2,>=1.6.6; extra == 'agents'
Requires-Dist: langchain-openai<2,>=1.6.7; extra == 'agents'
Requires-Dist: langgraph<1.3,>=1.2.12; extra == 'agents'
Provides-Extra: signing
Requires-Dist: cryptography<51,>=50; extra == 'signing'
Description-Content-Type: text/markdown

# VeloPhex.Python — the `velophex` SDK

The Python half of the VeloPhex hybrid platform: the library a Python automation is written
against, the worker the Runner executes it with, and the CLI that packs and publishes it.
New to VeloPhex? Start at `docs/START-HERE.md` at the root of the repository.

A Python automation is an ordinary [uv](https://docs.astral.sh/uv/) project with a
`project.json` and one or more functions marked `@velophex.entrypoint`. `velophex pack` turns
it into the same signed `.nupkg` a Studio workflow produces, so the Orchestrator stores, places
and starts both the same way.

```python
import velophex
from velophex import runtime


@velophex.entrypoint(id="main", display_name="Total an invoice")
def run(pdf_path: str, pages: int = 1) -> dict[str, float]:
    """Total one invoice."""
    if not pdf_path.endswith(".pdf"):
        raise velophex.BusinessError("Only PDF invoices are accepted.", code="INVOICE_REJECTED")

    api_key = runtime.secret("ErpApiKey")
    return {"total": 42.5}
```

Files go through Orchestrator storage buckets, the twins of the workflow storage activities:
`runtime.upload_file`, `download_file`, `list_files` (prefix, `*.pdf;*.xlsx` patterns, recursive),
`delete_file`, `read_text` and `write_text`. A bucket is named and must exist; none is created implicitly.

## Verifying this component

```powershell
cd VeloPhex.Python
uv sync                     # creates .venv and writes uv.lock (Python 3.12)
uv run pytest               # the whole suite
uv run ruff check           # lint
uv run ruff format --check  # formatting
```

`uv` picks the interpreter from `requires-python` (`>=3.12,<3.13`, ADR-P008). The contract
conformance tests read `VeloPhex.Platform.Contracts` from the sibling folder; if the components
are checked out apart, point at it:

```powershell
$env:VELOPHEX_CONTRACTS_DIR = "<path>\VeloPhex.Platform.Contracts"
uv run pytest
```

Tests that need it skip with a message rather than failing.

## What is where

| Path | What it is |
|---|---|
| `src/velophex/_json.py` | JSON that is byte-identical to `PlatformJson` in .NET. The escaping, the layout and the number literals are all part of the contract |
| `src/velophex/entrypoints.py` | `@entrypoint`; derives input/output JSON Schema from type hints with pydantic `TypeAdapter` |
| `src/velophex/errors.py` | `BusinessError` (a `Business` fault, never retried) and `VelophexError` |
| `src/velophex/contracts/` | `project.json`, package manifest v2, executor protocol messages, runtime kinds, the lexical rules — mirrors `VeloPhex.Platform.Contracts` |
| `src/velophex/worker/` | `python -m velophex.worker`: framing, the session state machine, the `logging` bridge and the handler loader |
| `src/velophex/runtime/` | `stop_requested()`, `context()` and the httpx client for `/api/v1/runtime/*` |
| `src/velophex/webhooks.py` | `verify()`: checks a webhook delivery's `VeloPhex-Signature` |
| `src/velophex/packaging.py` | The deterministic `.nupkg` builder, the twin of the engine's `AutomationPackageBuilder` |
| `src/velophex/cli/` | `velophex init \| run \| pack \| publish \| login \| logout` (typer); `signin.py` is the browser sign-in and the stored session |
| `src/velophex/agents/` | `velophex.agents.run`: the agent tool-calling loop (extra `velophex[agents]`); `testing.py` has the fake gateway and fake tools |
| `agent-runner/` | The `Agent.Runner` package every agent runs; see its README for how CI builds and signs it |
| `tests/data/sample_project/` | A Python project whose `project.json` was written by the **.NET** serializer |
| `tests/data/golden/` | The package the **.NET** `AutomationPackageBuilder` produced from that project |

## Quickstart: your first local run

You need [uv](https://docs.astral.sh/uv/) and nothing else; uv fetches CPython 3.12 itself.
`velophex` is on PyPI, so four commands take you from an empty folder to a run:

```powershell
uvx --python 3.12 velophex init Hello.World
cd Hello.World
uv sync
uv run velophex run main
```

The last command logs `INFO hello_world.main: Hello from VeloPhex` and prints
`{"message": "Hello from VeloPhex"}`. These are the steps `velophex init` prints. The generated
handler, `src/hello_world/main.py`, takes one input, `message`, with a default. `uv sync` writes
`uv.lock`, which every package must carry.

Inputs go in a file or inline:

```powershell
echo '{"message": "Hi"}' > inputs.json
uv run velophex run main --inputs-file inputs.json
```

`--inputs '{"message": "Hi"}'` works too where the shell passes the quotes through (PowerShell
7.3+, bash). Windows PowerShell 5.1 strips them, so use a file there. Exit codes: `0` success,
`1` failed, `2` unusable inputs, `3` a business rule. An error the SDK expects prints one line;
`--verbose` adds debug logging and the traceback.

PyPI may lag the SDK in this repository: the version PyPI serves is the one `uvx` installs, and
uploading a new one needs the owner's approval (see "Building and publishing the SDK"). To use
the SDK from a checkout, see "From a checkout" below.

### More entry points

Decorate another function in a module beside `main.py`, and run it by its id:

```python
@velophex.entrypoint(id="report")
def report(month: str) -> dict[str, int]:
    return {"rows": 0}
```

```powershell
uv run velophex run report --inputs '{"month": "2026-09"}'
```

The first run of an id `project.json` does not have adds it, with its schemas, and says so.
`velophex pack` does the same for every new handler before it builds.

### Local runs: assets and secrets

A local run has no Orchestrator, so `runtime.asset`, `runtime.secret` and `runtime.credential`
have nothing to ask. Give them a file instead:

```json
{
  "ErpBaseUrl": "https://erp.example.com",
  "BatchSize": 50,
  "DryRun": true,
  "ErpApiKey": { "secret": "s3cret" },
  "Erp": { "userName": "robot", "password": "p@ss" }
}
```

```powershell
uv run velophex run main --assets-file local-assets.json
```

A string is a Text asset, a number an Integer, `true`/`false` a Boolean, `{"secret": ...}` a
Secret, and `{"userName": ..., "password": ...}` a Credential. `runtime.secret("Erp.password")`
reads a credential's password, as it does on a Robot. `init` adds `local-assets.json` to
`.gitignore`, because it holds secrets. Queues, storage buckets, Data Hub and child jobs still
need an Orchestrator and fail with a one-line error.

### Local runs against an Orchestrator

`--orchestrator` makes the local run a design-time run of a real Orchestrator, the Python twin of
Studio's F5. Nothing is published: the handler runs here and reaches assets, queues, storage and
Data Hub through the Runtime API, acting as you in the workspace.

```powershell
uv run velophex login --orchestrator https://orchestrator.example.com     # once; see "Signing in"
uv run velophex run main --orchestrator https://orchestrator.example.com --workspace Finance
```

`--tenant` and `--workspace` default to `VELOPHEX_TENANT_ID` and `VELOPHEX_WORKSPACE`, then to the
tenant approved at `velophex login`; the workspace is an id or a name, and may be left out when you
can start jobs in only one. The token comes from `VELOPHEX_API_KEY` or `--api-key-stdin` when given,
else from the `velophex login` session, never an option. You need `jobs.create` in the
workspace and the tenant's design-time runs on (Settings > Security). The run ends with its outcome
(Succeeded, Failed, or Canceled on Ctrl+C) and is then visible as a design-time job, recorded with
the `python` runtime (Orchestrators with Contracts 0.8.73 or later; older ones record a workflow run).

### From a checkout

Contributors, or anyone ahead of PyPI, build the wheel and point uv at it:

```powershell
<checkout>\VeloPhex.Python\build\Build.ps1       # -> VeloPhex.Python\artifacts\dist
$env:UV_FIND_LINKS = "<checkout>\VeloPhex.Python\artifacts\dist"
uvx --python 3.12 --from velophex velophex init Hello.World
```

`init` finds the SDK wheel through `UV_FIND_LINKS` and copies it into `wheels/`. The project
then restores on a Robot with no package index at all (see "How a Robot gets the SDK"). From
PyPI, `init` has no wheel file to copy. It warns that a Robot restores the SDK from its package
index (`Python:IndexUrl`, PyPI by default), and that is fine for any Robot that can reach one.

## Signing in

The intended setup is `pip install velophex` (or `uv tool install velophex`), then one sign-in:

```powershell
velophex login --orchestrator https://staging.velophex.com --tenant velo-test/default
```

The system browser opens the Orchestrator's approval page; approve it and the CLI is signed in.
`publish` and `run --orchestrator` then need no URL, tenant or token. `--orchestrator` also takes
a tenant page pasted from the browser (`https://staging.velophex.com/velo-test/default/orchestrator_`),
which names the tenant too. `--tenant` (an id, a name, a slug or `org/tenant`) only preselects the
tenant on the approval page; the session is bound to the one you approve, and `login` prints it.
After the first time, `velophex login` with no arguments signs in again to the same Orchestrator
and tenant. `--no-browser` prints the approval page's address instead of opening it.

| | |
|---|---|
| Flow | OAuth 2.0 Authorization Code + PKCE (S256) on a loopback redirect, client `velophex-cli`, the same as Studio and the Assistant (ADR-P014). The browser returns to `http://127.0.0.1:{48620-48639}/callback` within 5 minutes. |
| Session | A `vpx_pat_…` access token (the SDK token preset, bound to the approved tenant) renewed with a rotating refresh token; the sign-in lasts 30 days. |
| Storage | One file per user, one session per Orchestrator: `%LOCALAPPDATA%\VeloPhex\cli\sessions.dat` encrypted with DPAPI for the current Windows user; `~/.config/velophex/cli/sessions.dat` (mode 0600) elsewhere. Never printed. |
| Precedence | `--api-key-stdin` or `VELOPHEX_API_KEY` (CI), then the stored session (renewed within a minute of its expiry), else an error that says to run `velophex login`. |

`velophex logout [--orchestrator URL]` revokes the sign-in on the Orchestrator and deletes it here;
without `--orchestrator` it signs out of the last Orchestrator signed in to.

## Publishing to an Orchestrator

From the project folder, signed in (`velophex login`) with `packages.create` in the tenant:

```powershell
uv run velophex pack                            # -> artifacts\Hello.World.1.0.0.nupkg
$env:VELOPHEX_SIGNING_PASSWORD = "..."          # or --sign-password-stdin, or be prompted
uv run velophex publish artifacts\Hello.World.1.0.0.nupkg --sign signer.pfx
```

In CI, where nobody can approve a browser sign-in, use a personal access token instead:
`$env:VELOPHEX_API_KEY = "vpx_pat_..."` (or `--api-key-stdin`) with `--orchestrator` and
`--tenant <tenantId>` (or `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`).

#### Signing

A production Orchestrator ships with `Packages:RequireSignedPackages` on and refuses an unsigned
package, or one whose signer it does not trust. `pack` and `publish` both sign, with either:

| Option | Signs with |
|---|---|
| `--sign signer.pfx` | A PKCS#12 file. Its password comes from `VELOPHEX_SIGNING_PASSWORD`, stdin (`--sign-password-stdin`) or a hidden prompt, never from an option. |
| `--sign-thumbprint <thumbprint>` | A certificate in the Windows `CurrentUser\My` store, by SHA-256 or SHA-1 thumbprint. Windows signs with its key (CNG), so a non-exportable key works, such as the one Studio creates. |

Signing needs the `signing` extra (`uv add "velophex[signing]"`). The signature is the detached
`velophex/signature.json` Studio writes (ADR-P003, ADR-P012). Signing prints the signer's SHA-256
thumbprint. `publish --sign` signs the package in place; a package already signed by that
certificate is published as it is, and one signed by another certificate is refused.

Before uploading, `publish` reads `GET /api/v1/tenants/{tenantId}/package-signing` (`packages.view`)
and stops, without uploading, when the tenant would refuse the package:

| Situation | `publish` says |
|---|---|
| Signing required, package unsigned | This tenant only accepts signed packages. Sign it with --sign <file.pfx> or --sign-thumbprint <thumbprint>, then publish again. |
| Signed by a certificate the tenant does not trust | This tenant doesn't trust the signing certificate. An admin adds its pin in the Orchestrator under Settings > Package feeds: `spki-sha256:…` |

An Orchestrator without the endpoint, or one `publish` cannot read it from, skips the check, and
the upload's own refusal decides. A tenant trusts the deployment's signers
(`Packages:TrustedSignerThumbprints`) and its own `trustedSignerPins` (ADR-P025). Robots refuse a
package the same way unless its signer is in their trusted signers (`configure --trusted-signers`,
`VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`) or the machine accepts tenant signers.
Signers are pinned by thumbprint or public key, never by certificate authority. On an evaluation
stack with signing off, no signing option is needed.

The tenant id is the GUID in the tenant's feed URL (`/api/v1/tenants/<tenantId>/feed/v3/index.json`,
`VeloPhex.Orchestrator/docs/package-feed.md`).

### How a Robot gets the SDK

A Robot restores the package's environment with `uv sync --frozen`, which installs from exactly
the sources `uv.lock` records and ignores any index or `--find-links` given at sync time. A lock
made against a wheel *folder* records that folder's absolute path, and it only restores where
the folder exists. So `init` **vendors** the SDK instead: it copies this SDK's own wheel into
the project's `wheels/` folder (from `--sdk-wheel`, or found in the `UV_FIND_LINKS` folders) and
writes

```toml
[tool.uv.sources]
velophex = { path = "wheels/velophex-<version>-py3-none-any.whl" }
```

`uv.lock` then records the wheel by a path relative to the project and by its SHA-256, and
`velophex pack` carries `wheels/` in the package like any other project file (hashed into the
manifest and signed). The Robot's `uv sync --frozen` restores the SDK from the unpacked package
itself, with no index and no Robot configuration, and it never asks any index for a package
named `velophex` - which is what closes the dependency-confusion gap (PY-02). The SDK's own dependencies (pydantic, httpx, typer) come from the Robot's index
(`Python:IndexUrl`, PyPI by default) or, when it reaches none, from the wheelhouse the Robot's
Full installer ships. That wheelhouse holds exactly the versions this SDK's `uv.lock` pins, so
`init` also writes them as `[tool.uv] constraint-dependencies`, and `package = false` so the
project is never built (the worker puts `src/` on `sys.path` itself) and needs no build backend.

Commit `wheels/` with the project. To move a project to a newer SDK, replace the wheel, update
the path in `pyproject.toml` and run `uv lock`. When `init` finds no wheel it falls back to a
plain `velophex` dependency and warns that a Robot cannot restore the project until the SDK is
on an index it can reach.

Why not ship the wheel with the Robot instead: under `--frozen` the lock's recorded path wins,
so a wheel in the Robot's payload would only be used if every developer machine had it at the
same absolute path. Verified with uv 0.12.15; vendoring needs no Robot change at all.

Every other dependency must ship a wheel too. A Robot restores with `uv sync --no-build`, because
building a package from source runs that package's own build code as the Robot service, so a
locked dependency with only an sdist fails the restore on the Robot, naming it. `velophex pack`
reads `uv.lock` and warns about each such package before you publish: one whose lock entry has an
sdist but no `py3-none-any`, `cp312-*-win_amd64` or `abi3` `win_amd64` wheel. Lock a version that
ships a wheel, or vendor the wheel in `wheels/` as above. The escape hatch is per machine, not per
package: `Python:AllowSourceBuilds` in the Robot's configuration (default `false`) lets that Robot
build from source; turn it on only where every index the Robot reaches is trusted
(`VeloPhex.Robot/docs/deployment/ROBOT-DEPLOYMENT.md`, "Wheel-only restores").

## Building and publishing the SDK

```powershell
build\Build.ps1      # wheel + sdist into artifacts\dist, built twice and compared byte for byte, plus SHA256SUMS
build\Publish.ps1    # dry run: checks SHA256SUMS and runs `uv publish --dry-run`; uploads nothing
```

The version has one source, `__version__` in `src/velophex/__init__.py`; hatchling reads it.
Publishing is an outward action that needs the owner's approval: `Publish.ps1 -Publish`
uploads (`velophex` is on PyPI since 0.2.0), and refuses a public index if `pyproject.toml`
carries the `Private :: Do Not Upload` classifier (PyPI rejects that classifier too). Credentials come
from `UV_PUBLISH_TOKEN` or trusted publishing, never from a parameter.

The token is never a command-line option (it would land in shell history and the process
list); `--api-key` is refused with an error that says where to put it instead.

`pack` refreshes `project.json` from the decorated handlers before it builds (`--no-sync` to
skip), requires `uv.lock`, writes the manifest with the SHA-512 of every content file, and
computes `requiredRuntimes` as this project's runtime plus every `automationDependencies`
runtime — which is what the Orchestrator matches against a runner's reported runtimes.

`publish` is a `PUT {publicBaseUrl}/api/v1/tenants/{tenantId}/feed/v3/package` with the
package as multipart and `X-NuGet-ApiKey: vpx_pat_…`. A `409` is success only when the
feed answers `package_binary_duplicate` with a `sha256` equal to the local package's: these
exact bytes are already published. Any other `409` - different bytes under a published version -
fails, naming the package and version: a published version never changes, so increment the
version in `project.json` and pack again. Without a path, `publish` takes the one package in
`--output` (`artifacts`) and refuses to guess when there are several. The URL and tenant come from
the options, then `VELOPHEX_ORCHESTRATOR_URL` and `VELOPHEX_TENANT_ID`, then the `velophex login`
session; the token from `VELOPHEX_API_KEY` or stdin (`--api-key-stdin`), then the session.

## How the worker runs an automation

The Runner prepares the environment with `uv sync --frozen --no-dev` and then starts
`<env>\Scripts\python.exe -m velophex.worker` (ADR-P008). Parent and worker speak the executor
protocol (ADR-P005) over the process's stdin and stdout:

1. The worker takes the session key out of `VELOPHEX_EXECUTOR_SESSION_KEY` and **removes the
   variable**, before any automation code is imported. It ignores `VELOPHEX_EXECUTOR_FEED_CREDENTIALS`
   (executor protocol §1): a Python run restores no NuGet packages, and the Runner never sets it for one.
2. The frame pipe moves off standard output to a private, non-inheritable descriptor, and fd 1,
   the Win32 standard output handle and `sys.stdout` all point at `stderr`. A `print()`, a child
   process (`os.system`, `subprocess`) or a `WriteFile` on the standard handle therefore cannot
   corrupt the frame stream, and a detached grandchild cannot keep the pipe open after the worker exits.
   The parent's pipe moves off standard input the same way, and fd 0, the Win32 standard input
   handle and `sys.stdin` become the null device: an automation or a child process that reads
   standard input gets end-of-file instead of consuming the parent's frames.
3. `hello` → `hello-ack` (runtime `python`, capabilities `should-stop` and `heartbeat`: the
   environment is already prepared, and there is no Python debugger over this protocol yet).
   From then until the terminal message a `heartbeat` goes out every 15 seconds from a thread of
   its own (0.2.2, contracts 0.8.50); a parent ends a worker it has not heard from for 60 seconds.
4. `start` names a package root and an entry point id; `project.json` turns the id into
   `module:function`, the module is imported and the inputs are validated against the
   handler's signature before it is called.
5. While it runs, `logging` records become `log` messages and the parent's `stop` sets
   `velophex.runtime.stop_requested()`.
6. Exactly one terminal message: `result`, `canceled` or `fault`.

Fault mapping, which decides retries:

| Raised | Fault |
|---|---|
| `velophex.BusinessError` | `Business` / the error's `code`, never retried |
| inputs that do not fit the handler | `Application` / `INVALID_INPUT`, not retried |
| a package or handler that will not load | `Application` / `INVALID_PACKAGE` |
| anything else | `Application` / `UNHANDLED_EXCEPTION` |
| a return value with no JSON form (`datetime`, a set, NaN) | `Application` / `RESULT_NOT_SERIALIZABLE`, not retried |
| outputs over the frame limit (16 MiB) | `Application` / `RESULT_TOO_LARGE`, not retried |

The last two are checked before the terminal frame is written, so the parent always gets a
terminal message rather than a crash it would have to report (and retry) as
`EXECUTOR_CRASHED`. Both are entries in the shared `FaultCodes` table (Contracts 0.1.0-preview.4), so the workflow executor uses the same spellings.

A stop is cooperative: the flag is set and the handler is expected to notice. Just before the
grace period ends (a second before, or halfway through a grace under two seconds), an `async`
handler's task is cancelled, so it unwinds through `finally` before the parent kills the
process tree at the deadline; a grace of 0 means "no grace" and cancels it at once. A
synchronous handler cannot be interrupted safely from outside, so the parent's kill is the
answer, and the worker logs a warning saying so. A handler that returns *after* a stop was
asked for ends the run as `canceled`, not `result`, with the reason `Timeout` for a timeout
stop and `User` for every other.

What the worker does when the channel itself goes wrong (0.2.2, contracts 0.8.50):

- A frame the parent sends during the run that fails the protocol ends the run: the terminal
  message is `Infrastructure` / `EXECUTOR_PROTOCOL_VIOLATION` and the exit code is 2, as for the
  workflow executor.
- A `sub-result` for a request nobody waits for (one that timed out, or a repeated answer) is
  logged at Warning and ignored.
- `invoke_automation` raises in the caller when the request cannot be sent (a value with no JSON
  form, a parent that is gone), and gives up after `timeout_seconds` plus 60 seconds.
- A frame write that fails breaks the writer: nothing more is written on that channel.

Runtime API calls that `POST` carry an `Idempotency-Key` and are sent again, with the same key,
after a dropped connection or a 502/503/504, so a retried queue completion or child job start
has one effect.

## Flow messages

`velophex.runtime.send_flow_message(name, correlation_key=None, variables=None, *, token)`
continues a flow from Python (0.2.1): it resolves every flow waiting for the message, starts the
flows that begin with it, or buffers it for `flows.messageTtl`. It calls
`POST /api/v1/tenants/{t}/workspaces/{ws}/flows/messages` (`VeloPhex.Orchestrator/docs/flows/message-api.md`)
in the job's tenant and workspace.

```python
from velophex import runtime

result = runtime.send_flow_message("invoice-paid", "INV-1042", {"amount": 1250}, token=runtime.secret("FlowsToken"))
result.outcome  # "correlated", "started" or "buffered"
result.instance_ids  # the flow instances it resolved or started
result.buffered_until  # when a buffered message expires
```

- `token` is an access token with `pulse.message` (a personal access token or an application
  token), kept in a secret asset. The job's execution token cannot send flow messages.
- The `Idempotency-Key` defaults to the job id and the call site, so a retried attempt delivers
  nothing twice; pass `idempotency_key=` to choose your own.
- A refusal raises `RuntimeApiError` with the problem's `code` (`too_many_matches` for more
  than 100 matches: send the message with a correlation key).
- Outside a run, `velophex.runtime.client.send_flow_message(orchestrator_url, token, tenant_id,
  workspace_id, name, idempotency_key=...)` takes everything explicitly.

## Data Hub

Typed business records of the job's workspace (0.3.0, runtime capability `data-hub/1`;
`VeloPhex.Orchestrator/docs/data-hub.md`). An entity is addressed by its API name and its
fields by theirs, so an automation runs unchanged in another workspace with the same entity.
The tested quickstart (an `Invoice` entity, `velophex run --orchestrator`, the expected output) and
the filter, value-type, limit and error references are in that page.

```python
from decimal import Decimal

from velophex import runtime
from velophex.runtime import where

result = runtime.upsert_record("Invoice", "invoiceNumber", {"invoiceNumber": "INV-23", "amount": Decimal("1204.50")})
result.created  # True the first time, False after

for invoice in runtime.iter_records("Invoice", where("amount").gte(Decimal("1000")), sort="amount desc"):
    print(invoice["invoiceNumber"], invoice["amount"])  # amount is a Decimal

runtime.update_record("Invoice", result.record.id, {"status": "paid"}, revision=result.record.revision)
```

| Call | Does |
|---|---|
| `list_entities()`, `entity(name)`, `entity_json_schema(name)` | The workspace's entities, one entity's fields, the JSON Schema of a record |
| `query_records(entity, filter, *, match, sort, select, page_size, cursor, include_total)` | One page (`next_cursor` for the next); `page_size` up to 1,000 |
| `iter_records(entity, filter, *, ..., limit=10_000)` | Every match, page after page; `DataHubResultTooLargeError` past `limit` or 64 MiB, `limit=None` lifts it |
| `get_record(entity, id)` | One record |
| `create_record(entity, data, *, idempotency_key=None)` | A new record; the same key and data within 24 hours return the first one |
| `upsert_record(entity, key_field, data)` | Create, or update the record with that unique key; `.created` says which |
| `update_record(entity, id, values, *, revision=None)` | Only the fields given change; `None` clears one; `revision` refuses a stale write |
| `delete_record(entity, id)` | Only where the entity's Settings allow automations to delete |

- **Filters** are `where("field").op(value)` combined with `&` and `|` (`eq ne in_ not_in gt gte lt
  lte contains starts_with is_null is_not_null`), a dict AST, or the JSON the web app's "Copy filter as
  JSON" gives. `match={"status": "open"}` adds equalities. Text compares case-insensitively.
- **Sort** is the one string every surface uses: `"amount desc, invoiceNumber"`. Only indexed and
  system fields sort.
- **Values** are typed from the entity's schema: decimal `Decimal` (sent as a string, never a float),
  date `date`, datetime an aware UTC `datetime` (a naive one is refused), number `int`. A record's
  system fields are attributes (`id`, `revision`, `created_at`, ...); its values are `record["name"]`.
- **Refusals** are `DataHubError` subclasses by code, each with `cause`, `fix` and `docs_url`:
  `EntityNotFoundError`, `RecordNotFoundError`, `RecordValidationError` (`.errors` by `data.<field>`),
  `UniqueViolationError` (`.field`), `RevisionConflictError`, `SchemaChangedError`,
  `FilterInvalidError` (`.pointer`), `CursorInvalidError`, `FieldNotIndexedError`, `QueryTooExpensiveError`,
  `KeyFieldNotUniqueError`, `IdempotencyKeyReusedError`, `DataHubLimitReachedError`,
  `DeleteDisabledError`, `DataHubAccessDeniedError`, `DataHubDisabledError` (the tenant has it
  off) and `DataHubUnsupportedError` (the Orchestrator predates it).
- A filter on a field that is not indexed works on small entities and raises a `DataHubWarning`
  (also logged); above 50,000 records it is refused with `FieldNotIndexedError`. Index the field.

## Integration connections

Vendor credentials the Orchestrator holds for the workspace (0.4.0, runtime capability
`integration-connections/1`, ADR-P026). The code names a connection by its binding, a connector
key and the connection's name, and declares it in `project.json`; the package never carries a
connection id, so the same package uses each workspace's own `Sales` connection.

```json
"integrationConnections": [{ "connector": "salesforce", "name": "Sales" }]
```

```python
import httpx

from velophex import runtime

sales = runtime.connection("salesforce", "Sales")
limits = httpx.get(f"{sales.instance_url}/services/data/v61.0/limits", headers=sales.auth_headers())
```

| Call | Does |
|---|---|
| `connection(connector, name)` | A handle: `.token`, `.instance_url`, `.auth_headers()`, `.auth_kind`; it leases again at 80% of each lease's lifetime |
| `lease(connector, name)` | The raw lease (`IntegrationConnectionLease`): at most 15 minutes, never past the job's attempt |

- **Secrets.** An OAuth or JWT connection's lease is a freshly minted access token. An api-key,
  basic or custom-header connection's lease is the stored secret itself, which cannot be revoked
  once released. Every leased secret is masked in what the worker writes; keep it out of outputs.
- **Where a name resolves.** A job searches its own workspace's shared connections; an attended or
  Studio run then also searches the personal workspace of the user it runs for.
- **Refusals** are `IntegrationConnectionError` subclasses by code, each with `cause`, `fix` and
  `docs_url`: `IntegrationConnectionNotFoundError` (`.searched_workspaces`),
  `IntegrationConnectionBrokenError` (`.reason`, `.fix_url`), `IntegrationConnectionDisabledError`,
  `IntegrationConnectionNotAllowedError` (the connection is for server-side use only),
  `IntegrationsDisabledError` (the tenant has Integrations off) and
  `IntegrationConnectionsUnavailableError` (the Orchestrator does not offer it).
- **`velophex pack`** warns about a literal `connection("...", "...")` or `lease(...)` in the code that
  `project.json` does not declare.
- **Local runs** read `"connections"` from `--assets-file`, by connector and then name (a string is
  an access token, an object has the lease's members), or the variable
  `VELOPHEX_CONNECTION_<CONNECTOR>_<NAME>_TOKEN` (upper case, every other character `_`):

```json
{ "connections": { "salesforce": { "Sales": { "accessToken": "00D...", "instanceUrl": "https://acme.my.salesforce.com" } } } }
```

### Starting from an event

An entry point a trigger starts gets the event (0.5.0, contracts 0.8.78, ADR-P028):

- **Standard inputs.** Declare any of `VeloPhexEventConnector`, `VeloPhexEvent`,
  `VeloPhexEventObjectType`, `VeloPhexEventObjectId` and `VeloPhexEventPayload` (the payload as
  JSON text) as `str` parameters, optional with a default. They are filled from the event and a
  trigger mapping cannot target them.
- **`runtime.context_event()`** returns the `EventContext` (`connector`, `source`, `event`,
  `object_type`, `object_id`, `payload`, `occurred_at`, `trigger_id`, `sample`), or `None` on a
  manual start. An event over 256 KiB keeps only `{"velophextruncated": true, "velophexrecordid": ...}`
  as its payload (`event.truncated`); fetch the record by that id.
- **Locally**, `velophex run main --event event.json` runs with a sample event: the standard inputs
  the entry point declares are filled from it, and `context_event()` returns it with `sample` set.
  With `--orchestrator` the sample is also sent with the design-time run.

```json
{ "connector": "http", "source": "http-poll", "event": "record.changed", "objectId": "ord_1001", "payload": { "id": "ord_1001", "status": "open" } }
```

## Priority

Jobs and queue items share the Orchestrator's 0–100 priority scale, higher first (0.2.3).
`runtime.add_queue_item(..., priority=)` and `runtime.start_job(..., priority=)` take a
`runtime.Priority` band or any `int`; the band is sent as its number.

```python
from velophex import runtime

runtime.add_queue_item("Invoices", {"invoice": "INV-1"}, priority=runtime.Priority.HIGH)  # 75
runtime.start_job(automation_name="Reconcile", priority=90)
```

`Priority` is `LOW` 25, `NORMAL` 50, `HIGH` 75 and `CRITICAL` 100, the Orchestrator's
`PriorityBands` (a test reads the C# table). `QueueItem.priority_band` and
`RuntimeJob.priority_band` name the band when the number is one, otherwise `None`.

## Verifying a webhook

`velophex.webhooks.verify(body, headers, secret, tolerance=300)` checks a delivery's
`VeloPhex-Signature` exactly as the Orchestrator signs it (`VeloPhex.Orchestrator/docs/webhooks.md`)
and returns the signed time in Unix seconds (0.2.3). It needs no run, so any receiver can use it.

```python
from velophex import webhooks


@app.post("/hooks/velophex")
async def receive(request: Request):
    try:
        webhooks.verify(await request.body(), request.headers, secret)
    except webhooks.WebhookSignatureError:
        return Response(status_code=401)
    ...
```

- Pass the raw body before any JSON parsing; re-serialised JSON does not match.
- `headers` is the request's headers (any case) or the header's value. A header with several
  `v1` values (a secret rotation) verifies when one matches.
- A timestamp more than `tolerance` seconds from this clock raises, so a captured delivery cannot
  be replayed later. To act exactly once, also remember the CloudEvent's `id`.
- `tests/test_webhooks.py` and the Orchestrator's `WebhookSignatureVectorTests` pin the same
  vector: change one and you must change the other.

## What "the same package as .NET" means

`tests/test_pack_parity.py` compares what `velophex pack` builds against
`tests/data/golden/Sample.Automation.1.2.0.nupkg`, which the engine's
`AutomationPackageBuilder` produced from `tests/data/sample_project`. Parity is asserted on
the archive's *content*: the same entry names in the same order, the same fixed
`2000-01-01T00:00:00` timestamp, and the same bytes in every entry — `content/project.json`,
`velophex/package-manifest.json`, the `.nuspec` and `[Content_Types].xml`.

The *compressed* bytes are deliberately not compared. A .NET `ZipArchive` and Python's `zlib`
do not emit the same deflate stream, and nothing in the platform depends on it: what is hashed
and signed is the manifest and the files the manifest lists, never the archive's compression.

Regenerate the golden only for an intentional packaging change, by running the .NET builder
over the same fixture directory.

## The JSON, and why it is not `json.dumps`

The package manifest carries the SHA-512 of `content/project.json`, so a manifest written here
and a manifest written by Studio only verify against each other when both writers agree byte
for byte. `System.Text.Json` and Python's `json` do not agree, in three ways that all matter
and are all pinned by tests:

* The default .NET encoder escapes `"`, `&`, `'`, `+`, `<`, `>`, `` ` `` and everything
  outside printable ASCII as `\uXXXX` with **upper-case** hex. So `">=3.12,<3.13"` is written
  `">=3.12,<3.13"`.
* A `JsonElement` — an entry point's JSON Schema, say — is re-emitted with its **original
  number literals**: `1.0` stays `1.0`, `1e2` stays `1e2`.
* A `DateTimeOffset` goes through a typed fast path that never reaches the encoder, so the
  `+` of its offset stays a `+` where the same text in a plain string property would become
  `+`. The executor frame vectors pin exactly this.

## Agents: `velophex[agents]`

`velophex.agents.run(definition, inputs)` runs an agent definition (`agent-definition` v1,
`VeloPhex.Platform.Contracts/schemas/agent-definition.v1.schema.json`) and returns its validated
output. The extra brings LangGraph, `langchain-openai` and `jsonschema` from PyPI; LangGraph is
pinned to one minor and only `velophex/agents/_loop.py` imports it.

* **Model calls** go to the Orchestrator's LLM gateway, `POST <runtime>/ai/chat/completions`, in
  the OpenAI chat completions shape, non-streaming, with the job token and the agent's binding
  (`agent:default`) as the model. Outside a job, `VELOPHEX_AI_BASE_URL`, `VELOPHEX_AI_API_KEY`
  and `VELOPHEX_AI_MODEL` name any OpenAI-compatible endpoint; nothing is traced then.
* **Tools:** `process` runs a published automation **in this job** (`velophex.runtime.invoke_automation`,
  executor `sub-invoke`: same machine, same lease, no extra robot slot); `mcp`, `http`,
  `vector_search` and `action_task` go through the Runtime API. Every call carries an
  idempotency key, and tool and retrieved content reaches the model fenced as untrusted data.
* **Approvals** (`approval: before_call`) are bounded waits in the job: at most 900 s, and never
  past the run's own timeout. A rejected or expired approval means the call is not made.
* **Limits:** 12 model calls, 50 000 tokens and 15 minutes per run, temperature 0.2, unless the
  definition says otherwise. A limit ends the run with `agent_limit_exceeded`; an answer that
  fails the output schema gets one repair turn, then `agent_output_invalid`.
* **Faults keep their code.** A gateway refusal raises `AiBudgetExhaustedError`,
  `AiPolicyDeniedError`, `AiProviderUnavailableError` or `AiRequestInvalidError` (all
  `RuntimeApiError`), and the worker reports it under that code with the error envelope (cause,
  fix, request id, retry scope, docs URL), not as `UNHANDLED_EXCEPTION`. A run that fails after a
  tool call succeeded reports `effectsCommitted` and is never retried automatically; an unknown
  tool outcome is `tool_outcome_unknown`, reconciled by a person.
* **Testing:** `velophex.agents.testing.FakeChatServer` (a scripted gateway on 127.0.0.1),
  `FakeTools` and `RecordingTracer`; `tests/test_agents.py` shows the patterns.

`velophex run` and the worker shape outputs with the same rule (`normalize_outputs`): `None` is
`{}`, a scalar or list is `{"value": ...}`, and a value with no JSON form is
`RESULT_NOT_SERIALIZABLE` in both.

## Contract changes

`project.json`, the package manifest, the runner protocol and the executor protocol are
platform contracts. They change only through the `velophex-contract-change` skill, which
updates the engine, Studio, the Activities, the Orchestrator, the Runner and this SDK together.
Nothing in `src/velophex/contracts/` may drift on its own; the conformance tests against
`VeloPhex.Platform.Contracts/schemas/` are there to make drift fail loudly.
