Metadata-Version: 2.5
Name: praxis-agent-sdk
Version: 0.11.0
Summary: Python SDK for the Praxis coding-agent harness — one agent API across every model and provider
Project-URL: Homepage, https://github.com/Facets-cloud/praxis-harness
Project-URL: Source, https://github.com/Facets-cloud/praxis-harness/tree/master/python
Project-URL: Issues, https://github.com/Facets-cloud/praxis-harness/issues
Author: Facets Cloud
License: Apache-2.0
Keywords: agent,anthropic,bedrock,coding-agent,gemini,llm,openai,vertex
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: tomli>=2.0; (python_version < '3.11') and extra == 'dev'
Description-Content-Type: text/markdown

# praxis-agent-sdk

Python SDK for the [Praxis](https://github.com/Facets-cloud/praxis-harness)
coding-agent harness. One agent API, **every model and provider** the harness
supports — Anthropic, OpenAI (including Codex), Google Gemini, Vertex, AWS
Bedrock, Z.AI, and anything else in its registry — with real tools, subagents,
sessions, MCP, skills, and automatic context compaction.

```bash
# the SDK, straight from the repo
pip install "git+https://github.com/Facets-cloud/praxis-harness.git@master#subdirectory=python"
# plus the binary it drives (a git install does not carry one — see below)
GOPRIVATE=github.com/Facets-cloud go install github.com/Facets-cloud/praxis-harness/cmd/prx@latest
```

There is **no Node.js requirement**, no `npm install -g`, and no separate CLI to
keep in version sync. Zero runtime dependencies, so it cannot conflict with your
service's tree.

## Interactive login

The SDK can drive the same local login flow as the TUI. Discovery exposes the
currently selectable providers and the providers intentionally marked as coming
soon; it never silently initiates a disabled provider.

```python
from praxis_agent_sdk import PraxisClient, PraxisOptions

async with PraxisClient(PraxisOptions()) as agent:
    providers = await agent.get_login_providers()
    assert not next(p for p in providers if p.id == "anthropic").coming_soon

    challenge = await agent.login_challenge("anthropic")
    # Open challenge.auth_url, or collect the API key / redirect URL described
    # by challenge.prompt_message, then keep the opaque challenge unchanged.
    completion_value = input(challenge.prompt_message)
    result = await agent.complete_login(challenge, completion_value)
```

`LoginChallenge.verifier` and `.state` are OAuth continuation values. They are
hidden from `repr`, but `complete_login()` needs the original object; do not
reconstruct it from display fields. A coming-soon provider is returned by
`get_login_providers()` with `coming_soon=True` and is rejected by both login
methods.

## Installing

### From git — supported, and what to use today

The package lives in the `python/` subdirectory of the harness repo, so every
installer needs to be told that.

```bash
# pip
pip install "git+https://github.com/Facets-cloud/praxis-harness.git@master#subdirectory=python"
pip install "git+ssh://git@github.com/Facets-cloud/praxis-harness.git@master#subdirectory=python"

# uv
uv add praxis-agent-sdk --git https://github.com/Facets-cloud/praxis-harness --subdirectory python

# a local checkout, editable
pip install -e /path/to/praxis-harness/python
uv add --editable /path/to/praxis-harness/python
```

Pin a release rather than tracking `master` in anything you deploy — replace
`@master` with a tag or commit, e.g. `@python-v0.2.0`.

**A git or path install does NOT include the binary.** Nothing compiles Go during a
Python install, and `_bin/` is gitignored on purpose so a platform-specific binary
never enters the source tree. `bundled_prx_version()` returns `None` there, which is
the honest answer. Supply `prx` by any one of:

```bash
# build it from the same checkout
cd /path/to/praxis-harness && make build && export PRAXIS_BIN=$PWD/bin/prx

# or install it with Homebrew (macOS, Linux)
brew install facets-cloud/tap/prx

# or install it with Go
GOPRIVATE=github.com/Facets-cloud go install github.com/Facets-cloud/praxis-harness/cmd/prx@latest

# or take a release archive, then put prx on PATH
# https://github.com/Facets-cloud/praxis-harness-releases/releases

# or point at it per session, no environment needed
python -c "from praxis_agent_sdk import PraxisOptions; PraxisOptions(executable='/path/to/prx')"
```

If `prx` cannot be found, the error names every one of these fixes rather than
saying "not found".

### From a release wheel — binary included, no PyPI needed

Every `python-v*` tag attaches platform wheels to its GitHub release, and a wheel
bundles the matching `prx`. That is strictly better than a git install for a
consumer, because nothing else has to be supplied:

```bash
pip install https://github.com/Facets-cloud/praxis-harness-releases/releases/download/python-v0.2.0/praxis_agent_sdk-0.2.0-py3-none-macosx_11_0_arm64.whl
```

Pick the wheel matching your platform from
<https://github.com/Facets-cloud/praxis-harness-releases/releases>. `bundled_prx_version()`
then reports the exact `prx` it carries.

### From PyPI

```bash
uv add praxis-agent-sdk              # uv project
uv pip install praxis-agent-sdk      # uv, pip-style
pip install praxis-agent-sdk         # pip
```

Platform wheels for macOS (arm64, x86_64) and Linux (x86_64, aarch64) each bundle
the matching `prx`, so pip/uv picks the right one and you need nothing else.
Releases from 0.11.0 on are published to PyPI; for an older version use a release
wheel or the git install above.

Resolution order: `PraxisOptions.executable` → `$PRAXIS_BIN` → the wheel's bundled
binary → `prx` on `$PATH`. Bundled beats `$PATH` deliberately — a wheel pins a
binary it was tested against, and silently preferring a stray `prx` makes version
skew invisible.

### Adding it to a `uv` service

```toml
# pyproject.toml
dependencies = ["praxis-agent-sdk>=0.2.0"]

# while unpublished, pin the source:
[tool.uv.sources]
praxis-agent-sdk = { git = "https://github.com/Facets-cloud/praxis-harness", subdirectory = "python" }
```

```bash
uv lock && uv sync
```

Then swap the Node layer for the binary in your image:

```dockerfile
# was: RUN npm install -g @anthropic-ai/claude-code
COPY --from=praxis /usr/local/bin/prx /usr/local/bin/prx
ENV PRAXIS_BIN=/usr/local/bin/prx
```

The SDK's floor is Python 3.10 so it never forces a consumer's floor upward;
`uv lock`/`uv sync`/`uv run` are verified against 3.14.

## Quick start

```python
import asyncio
from praxis_agent_sdk import PraxisOptions, query, AssistantMessage

async def main():
    async for message in query(
        "read README.md and summarise it in three bullets",
        PraxisOptions(model="claude-opus-5", cwd="/path/to/repo"),
    ):
        if isinstance(message, AssistantMessage):
            print(message.text)

asyncio.run(main())
```

Change one string to change provider — the model id resolves its own provider:

```python
PraxisOptions(model="gpt-5.6-terra")     # OpenAI
PraxisOptions(model="gemini-2.5-pro")    # Google
PraxisOptions(model="claude-opus-5")     # Anthropic
PraxisOptions(model="glm-4.6")           # Z.AI
```

## Credentials

**Nothing to configure.** If this machine is logged in — `prx login`, or the TUI's
provider picker — the agent authenticates from that login, whichever kind it is:
OAuth (a Claude or ChatGPT subscription) or an API key, resolved per provider from
the model you asked for. Provider keys in the environment (`ANTHROPIC_API_KEY`,
`OPENAI_CODEX_API_KEY`, …) keep working too; the child inherits your environment.

What the SDK does for you is keep that login reachable when your process gives the
agent a different `HOME`:

```python
# An isolated runtime: its own HOME, its own sessions, its own settings.
PraxisOptions(env={"HOME": str(runtime / "home")}, session_dir=str(runtime / "sessions"))
```

Praxis reads credentials from `$HOME/.praxis/agent/auth.json`, so a re-homed child
would resolve an empty store and fail to start with *no credential for provider* —
even though you are logged in. The SDK detects that the child would look somewhere
else and pins `PRAXIS_CODING_AGENT_AUTH_FILE` at the store this process uses. Only
the credential store is shared: sessions, skills, memories, and analytics stay under
the `HOME` you chose.

What travels is the store, never a token the SDK copied out of it — that is what
keeps OAuth working, since the harness still holds the refresh token, the account
id, and the credential type that decides a subscription's context window.

```python
# Authenticate from a specific store (a mounted secret, a service account's login).
PraxisOptions(credentials="/run/secrets/praxis-auth.json")

# Or opt out and let the child resolve its own credentials from the env you gave it.
PraxisOptions(env={"HOME": sandbox}, inherit_credentials=False)
```

## A live session

`query()` is one prompt. `PraxisClient` is a conversation you can steer.

```python
from praxis_agent_sdk import PraxisClient, PraxisOptions, AssistantMessage

async with PraxisClient(PraxisOptions(model="claude-opus-5", cwd=repo)) as agent:
    async for msg in agent.prompt("find the retry logic and explain it"):
        if isinstance(msg, AssistantMessage):
            print(msg.text)

    # Mid-turn course correction — the agent picks this up at its next tool
    # boundary instead of you interrupting and restating the whole task.
    await agent.steer("only look under internal/, skip the tests")

    await agent.set_model("gpt-5.6-terra")        # switch models, keep history
    usage, cost_usd = await agent.usage()
    print(f"{usage.billed} tokens, ${cost_usd:.4f}")
```

## Exact plan approval

Plan approval is bound to the proposal you reviewed. Enter plan mode, propose a
plan, fetch that proposal by its opaque id, then pass the same id to approval.
A proposal that changed, expired, or was never reviewed is rejected.

```python
async with PraxisClient(PraxisOptions(permission_mode="plan")) as agent:
    await agent.set_plan_mode(True)
    proposal = await agent.propose_plan()
    proposal_id = proposal["proposalId"]
    content = await agent.get_plan_proposal(proposal_id)
    print(content)
    await agent.approve_plan("ship", proposal_id)
```

## MCP server configuration

Use `persist_mcp_server()` to save a server for a later session without starting a
process now. Use `add_mcp_server()` when the current conversation needs it
immediately; it connects the server and persists the same configuration.

```python
async with PraxisClient(PraxisOptions()) as agent:
    path = await agent.persist_mcp_server("docs", "npx", "-y", "docs-mcp-server")
    print(f"saved MCP configuration in {path}")
```

## Tools that run in your process

```python
from praxis_agent_sdk import PraxisOptions, PraxisClient, tool

@tool("get_deployment", "Look up a deployment by id", tier="read")
async def get_deployment(deployment_id: str) -> str:
    return await api.fetch(deployment_id)

@tool("scale_service", "Change a service's replica count", tier="write")
async def scale_service(service: str, replicas: int) -> str:
    await api.scale(service, replicas)
    return f"scaled {service} to {replicas}"

options = PraxisOptions(model="claude-opus-5", tools=[get_deployment, scale_service])
```

`tier` is load-bearing:

| tier | meaning | approval |
|---|---|---|
| `read` | no side effects | auto-approved, never prompts |
| `write` | mutates something | needs a permission decision |
| `exec` | runs commands | needs a permission decision |

A `write`/`exec` tool without a `can_use_tool` callback is **denied**, not
stalled — the harness resolves the prompt locally rather than blocking a turn on
an answer that is not coming.

## Permissions

```python
from praxis_agent_sdk import PermissionDecision, PraxisOptions

async def can_use_tool(request):
    if request.tool_name == "bash" and request.destructive:
        return PermissionDecision(allow=False, reason="no destructive shell in prod")
    return PermissionDecision(allow=True)

PraxisOptions(model="claude-opus-5", permission_mode="ask", can_use_tool=can_use_tool)
```

Permission modes: `auto` (default — classifier-gated, reads free), `ask`
(everything non-read prompts), `yolo` (allow, minus deterministic red lines),
`plan` (read-only planning).

## Migrating from `claude-agent-sdk`

| Claude Agent SDK | Praxis SDK |
|---|---|
| `ClaudeAgentOptions` | `PraxisOptions` |
| `ClaudeSDKClient` | `PraxisClient` |
| `query(prompt, options)` | `query(prompt, options)` |
| `@tool(name, desc, schema)` | `@tool(name, desc, schema, tier=...)` |
| `create_sdk_mcp_server(...)` | deleted — pass `tools=[...]` |
| `options.mcp_servers={"x": server}` | `tools=[...]` for in-process; `mcp_config=` for real MCP servers |
| `can_use_tool` → `PermissionResultAllow/Deny` | `can_use_tool` → `PermissionDecision(allow=...)` |
| `options.model` (Anthropic only) | `options.model` (any provider) |
| `options.max_turns` | `options.max_turns` |
| `options.permission_mode` | `options.permission_mode` |
| `options.setting_sources` | `options.settings`, `no_rules`, `no_skills` |
| `ResultMessage.total_cost_usd` | `ResultMessage`, or `await client.usage()` |
| requires Node + `@anthropic-ai/claude-code` | nothing — the binary ships in the wheel |

The full port guide, including the wire protocol, is in
[`docs/design/PYTHON-SDK.md`](../docs/design/PYTHON-SDK.md).

## Using a specific binary

Resolution order: `PraxisOptions.executable` → `$PRAXIS_BIN` → the wheel's
bundled binary → `prx` on `$PATH`.

```bash
PRAXIS_BIN=/usr/local/bin/prx python app.py
```

## Development

```bash
cd .. && make build        # do this FIRST: the contract tests pin ./bin/prx
cd python
uv venv && uv pip install -e '.[dev]'    # or python -m venv .venv && .venv/bin/pip install -e '.[dev]'
uv run pytest              # whole suite, ~16s (contract tests drive the real binary)
uv run pytest -v --durations=0   # per-test progress; use this if a run looks stuck
uv run ruff check . && uv run mypy src/praxis_agent_sdk
uv build                   # sdist + wheel
```

The test suite spawns a real subprocess speaking the real protocol, so framing
and correlation bugs fail the tests rather than reaching production.
`tests/test_contract.py` additionally drives the REAL binary, which is what stops
the Go and Python halves of the protocol drifting.

Three things about running it:

- **The contract tests use `../bin/prx` and nothing else.** They deliberately do
  NOT fall back to a `prx` on `$PATH`: a stale binary answers a newly added command
  with `unknown command: X`, which reads like a protocol bug rather than a missing
  `make build`. Without the built binary they skip and say so. `PRAXIS_CONTRACT_BIN`
  overrides the path for CI layouts that build elsewhere.
- **No test may hang.** `pytest-timeout` caps every test at 60s and dumps the stack
  of all threads, because a hang blocks the whole suite and `-q` prints its summary
  only at the end — so a slow suite is indistinguishable from a stuck one.
- **`PRAXIS_TEST_BUILD=1` opts into the packaging build test**, which provisions an
  isolated environment and needs the network. It is skipped by default so the suite
  stays hermetic.

Runnable examples for every capability are in [`examples/`](examples/README.md).
