Python SDK
Use the Python SDK when an asyncio application should own Wisp in its process and consume the same typed command/event contract as the CLI and JSONL RPC interfaces. The SDK is presentation-free: the embedding application renders events, decides trust and approvals, and owns shutdown.
Wisp currently ships the runtime and client in the single wisp-ai distribution. A possible lightweight client package is still being evaluated in #409; do not install or depend on a separate SDK package today.
Install
Wisp requires Python 3.12 or newer. Add the stable release to an application environment rather than installing it only as a command-line tool:
uv add "wisp-ai==0.1.0"The supported embedding imports start here:
from wisp.config import WispConfig
from wisp.events import KnownWispEvent, RpcCommandFinished
from wisp.rpc import RpcController
from wisp.sdk import InProcessOptions, InProcessWispSee the SDK API reference for supported namespaces, signatures, and command groups.
Minimal offline prompt
This example uses Wisp's deterministic fake provider. It needs no credentials and makes no model provider network calls:
from __future__ import annotations
import tempfile
from pathlib import Path
import anyio
from wisp.config import WispConfig
from wisp.events import MessageDelta, RpcCommandFinished
from wisp.sdk import InProcessOptions, InProcessWisp
async def main() -> None:
with tempfile.TemporaryDirectory(prefix="wisp-sdk-") as temporary_directory:
root = Path(temporary_directory)
workspace = root / "workspace"
workspace.mkdir()
controller = await InProcessWisp.start(
WispConfig(
provider="fake",
session_dir=root / "sessions",
update_check_enabled=False,
),
options=InProcessOptions(
# Safe here because this application created the empty workspace.
startup_trusted=True,
project_context_root=workspace,
),
)
async with controller:
events = controller.events()
prompt_id = await controller.prompt("hello from the SDK")
async for event in events:
if isinstance(event, MessageDelta) and event.content_kind == "text":
print(event.delta, end="", flush=True)
if isinstance(event, RpcCommandFinished) and event.command_id == prompt_id:
if not event.ok:
raise RuntimeError(event.error or "Prompt failed")
break
print()
if __name__ == "__main__":
anyio.run(main)The maintained version is examples/sdk/minimal.py, and automated tests execute that same function.
Choose a startup path
Explicit configuration
InProcessWisp.start(config, options=...) uses the supplied WispConfig. It does not discover a project settings layer on its own. Use it when the application owns configuration and can state its trust decision explicitly.
startup_trusted=True is a security decision, not a convenience flag. Set it only after the caller has trusted the resolved project_context_root. Trust enables project settings, instructions, and skills. It does not approve unsafe tools.
Environment and saved settings
InProcessWisp.from_environment(...) applies the same environment, user settings, and project trust boundary as standalone RPC mode. If trust is undecided, the first prompt emits TrustRequested. When the application trusts the project, Wisp rebuilds the project configuration before starting that prompt.
Prefer this path when the embedder should honor the user's existing Wisp configuration. Explicit arguments such as provider, model, session_dir, and auth_path still override lower-precedence settings. See Configuration for precedence.
Own one event consumer
Call events() exactly once and drain it continuously while the controller is running. In-process events are ordered and bounded; when the consumer stops reading, streamed production eventually backpressures. Do not create one iterator per command or let rendering block the only consumer indefinitely.
Command methods submit typed requests and return their command IDs. They do not currently return the command result. Events from commands may interleave, so match command-scoped reports and RpcCommandFinished by command_id:
prompt_id = await controller.prompt("inspect the failure")
stats_id = await controller.get_session_stats()
pending = {prompt_id, stats_id}
async for event in controller.events():
if getattr(event, "command_id", None) == stats_id:
# Handle the stats report and its lifecycle events.
...
if isinstance(event, RpcCommandFinished) and event.command_id in pending:
if not event.ok:
raise RuntimeError(event.error or f"Command {event.command_id} failed")
pending.remove(event.command_id)
if not pending:
breakSome streamed agent events, including message deltas, describe the active run without carrying a command ID. Use command lifecycle events as the terminal correlation contract; do not infer completion from the last text delta or AgentCompleted alone.
For an interactive or concurrent application, keep one long-lived consumer that routes events to application state keyed by command ID. Awaitable command results and independent subscriptions are tracked in #400. Direct settled-state accessors are tracked in #401; current snapshot methods submit a command and return their report through the event stream.
Handle trust and approvals re-entrantly
Trust and unsafe tool execution pause the active command. Resolve their typed requests from the same consumer while continuing to drain events:
from wisp.events import ToolApprovalRequested, TrustRequested
async for event in controller.events():
if isinstance(event, TrustRequested):
await controller.trust(
event.request_id,
trusted=False,
transient=True,
reason="The application did not trust this project",
)
elif isinstance(event, ToolApprovalRequested):
await controller.approve(
event.call_id,
approved=False,
reason="The application did not authorize this tool call",
)Default deny is the safe fallback. An application may expose an approval prompt or apply its own policy, but model output must never grant project trust or tool permission. Approval scopes are once, tool_session, and all_session; broader scopes remain caller decisions.
InProcessOptions controls which tools the model can see. Tool exposure and approval are separate: all_tools=True or allowed_tools=(...) does not bypass approval for mutating or command tools. approve_unsafe_tools=True deliberately pre-approves them and should be reserved for a trusted, caller-controlled environment.
Read Tools & safety for protected paths, trust storage, and MCP policy. The complete deny-by-default handler is in examples/sdk/safety_requests.py.
Live control
The controller can modify an active run without replacing its transcript:
prompt_id = await controller.prompt("inspect the project")
await controller.steer("focus on the failing test")
await controller.follow_up("summarize the final diff")
await controller.cancel(prompt_id)
await controller.compact("retain decisions and test results")steer()injects text at the next eligible boundary in the active run.follow_up()queues text for when the run would otherwise stop.cancel(target_id)cooperatively cancels an active prompt or compaction.compact()is a sequential command and reports completion through events.- Queue inspection and editing use
get_queue_state(),set_queue_mode(),pop_queue(), andclear_queue().
Submit control commands while the single event consumer remains active. Each method returns its own command ID, so correlate its acceptance or failure independently. See Staying in sync and the deterministic control_requests.py example.
Persist and resume sessions
SDK sessions use the same append-only JSONL store as every other interface. Select startup behavior with InProcessOptions:
options = InProcessOptions(resume="SESSION_ID_OR_PATH")
# Or: InProcessOptions(continue_latest=True)resume and continue_latest are mutually exclusive. During a running controller, use the session command methods to list, select, name, clone, fork, and navigate persisted sessions. Their typed result events include the originating command ID.
Transcript reads are bounded. Page with get_messages(limit=..., before_entry_id=...) or after_entry_id=...; use the cursors from RpcMessagesReported rather than assuming the entire session fits in one response.
The runnable persisted_sessions.py example creates, resumes, inspects, clones, and forks a session using public events. True in-memory sessions and atomic active-session replacement are not shipped; they are tracked in #404. Read Sessions for durability and branching semantics.
Clean up explicitly
Prefer the async context manager after startup:
controller = await InProcessWisp.start(config, options=options)
async with controller:
...It calls aclose() even when the body raises. If ownership cannot be lexical, call await controller.aclose() in finally. Cleanup stops command processing and releases runtime-owned provider, MCP, and process resources. Do not rely on garbage collection.
shutdown() is a protocol command intended to ask an RPC host to exit; aclose()/close() is the client-side resource cleanup contract.
In-process or subprocess RPC?
Both choices expose RpcController and the same typed events:
| Choose | When |
|---|---|
InProcessWisp | The application uses asyncio, deliberately shares a process with Wisp, and owns runtime cleanup. |
JsonlSubprocessRpcTransport | You need process isolation, a non-asyncio parent, language-neutral JSONL, or a separate failure/restart boundary. |
In-process Wisp currently requires AnyIO's asyncio backend. From Trio or another runtime, place Wisp behind JSONL RPC. The public subprocess adapter starts wisp --mode rpc, serializes typed commands, and parses stdout into KnownWispEvent values:
from wisp.rpc import JsonlSubprocessRpcTransport, RpcController
transport = await JsonlSubprocessRpcTransport.start(cwd=workspace, env=child_environment)
controller = RpcController(transport)
try:
...
finally:
await controller.close()The caller owns child_environment. Isolate HOME and remove inherited WISP_* overrides when a run must be deterministic and offline. See the tested subprocess_rpc.py example.
Composition and current limits
Static source-checkout extension composition is demonstrated in examples/extensions. The current SDK does not accept an arbitrary caller-owned runtime or provider instance; that API belongs to #402.
Other planned APIs are intentionally not presented as available:
- typed system-prompt, context, skill, and template overrides — #403
- model, credential, and settings management — #405
- health, restart, recovery, and long-running observability primitives — #406
Use public namespaces only. Private names such as _InProcessTransport are implementation details and may change without notice.
Next steps
- SDK API reference — exact public controllers, options, commands, and events.
- SDK capability audit — pinned Pi comparison, intentional differences, and roadmap ownership.
- Canonical examples — deterministic, executable workflows covered by tests.
- Interfaces — compare SDK behavior with TUI, print, JSON, and RPC modes.
