Python SDK for programmatically driving the Otto agent binary
Project description
astronomer-otto-sdk (Python)
Python SDK for programmatically driving the otto agent binary. Spawns otto in --rpc mode and exposes an OttoClient async context manager and a query() async iterator over JSON-lines on stdin/stdout.
Useful for building your own agents on top of otto, or as a drop-in shape for any code that already shells out to a Claude-style coding-agent SDK.
Install
pip install astronomer-otto-sdk
The import path is otto_sdk. Zero runtime dependencies (stdlib only). Requires Python 3.10+.
Prerequisites
ottobinary installed. Resolution:otto_pathoption →$OTTO_PATH→~/.astro/bin/otto→shutil.which("otto").- Astro env vars in the calling process:
ASTRO_TOKEN,ASTRO_DOMAIN,ASTRO_ORGANIZATIONAIRFLOW_API_URL+ creds if the agent needs Airflow access
One-shot: query()
from otto_sdk import QueryOptions, query
async for event in query(QueryOptions(prompt="list dags", cwd="./project")):
if event["type"] == "tool_execution_start":
print(f"→ {event['toolName']}")
elif event["type"] == "message_end":
print(event["message"])
Or the batch variant:
from otto_sdk import QueryOptions, run_query
result = await run_query(QueryOptions(prompt="summarize", cwd="./project"))
print(result.final_text)
Multi-turn: OttoClient
from otto_sdk import OttoClient, OttoOptions
async with OttoClient(OttoOptions(cwd="./project", no_session=True)) as client:
await client.prompt("list the dags")
async for event in client.events():
... # stream events for this turn
await client.prompt("now describe the first one")
async for event in client.events():
...
state = await client.get_state()
print(state["messageCount"])
Events
AgentEvent is a dict with a required type key. Narrow on event["type"] (or match in 3.10+):
agent_start/agent_endturn_start/turn_endmessage_start/message_update/message_endtool_execution_start/tool_execution_update/tool_execution_end
See otto_sdk.protocol for the full TypedDict schema.
Pre-tool-use hooks
Async callbacks invoked over RPC before each tool call, exactly like the Claude
Agent SDK's PreToolUse hooks. Each hook receives the tool name + args and can:
- allow the call (short-circuits Otto's permission rule engine for that call)
- deny it with a reason (the agent surfaces the reason and replans)
- pass (no opinion — falls through to other hooks and Otto's permissions)
- optionally mutate the args before execution
Hooks are dispatched in registration order. First explicit deny wins; a later
hook's deny overrides an earlier allow. Hook exceptions and per-hook
timeouts are treated as deny — security-style hooks fail closed.
from otto_sdk import (
HookResult,
OttoClient,
OttoOptions,
PreToolUseHookEntry,
PreToolUseInput,
)
async def echo_only_bash(payload: PreToolUseInput) -> HookResult | None:
command = payload["tool_input"].get("command", "")
if str(command).strip().startswith("echo "):
return {"decision": "allow"}
return {"decision": "deny", "reason": "only `echo` commands are allowed"}
options = OttoOptions(
pre_tool_use_hooks=[
PreToolUseHookEntry(matcher="bash", hook=echo_only_bash, name="echo-only"),
],
)
async with OttoClient(options) as client:
await client.prompt("Run bash: echo hello")
...
Matchers
PreToolUseHookEntry.matcher decides which tools a hook fires on. The SDK
filters by matcher before invoking, so hooks never need defensive
if tool_name != ... checks.
"*"— match every tool."bash"— exact name, case-insensitive."bash|webfetch"— pipe-separated alternatives, case-insensitive.re.compile(r"^mcp__")— regex against the verbatim tool name.frozenset({"a", "b"}).__contains__— callable predicate (best for registry-driven gating).
Tool names arrive verbatim in the input. Pi built-ins are lowercase
("bash", "read"); MCP tools keep their MCP-server-given casing
("mcp__astro_tools__Astro_RunDAGOnTestDeployment").
Long-blocking hooks (UI approval gates)
Hooks can legitimately block for tens of minutes — e.g. polaris's approval
workflow blocks on Redis pub/sub waiting for the user to click Approve. Set
timeout_ms per hook (the global default is 30s):
PreToolUseHookEntry(
matcher=APPROVAL_REQUIRED.__contains__,
hook=approval_hook,
timeout_ms=1_800_000, # 30 minutes
)
A timeout produces deny with reason "hook <name> timed out". Otto itself
imposes no ceiling by default; set OTTO_PRE_TOOL_USE_TIMEOUT_MS env var for
an ops kill-switch.
Patching tool args
Return tool_input to fully replace the args (it's a full replacement, not a
merge):
async def add_timeout(payload: PreToolUseInput) -> HookResult:
patched = dict(payload["tool_input"])
patched.setdefault("timeout", 30)
return {"decision": "allow", "tool_input": patched}
Known Pi quirk: the
tool_execution_startevent Pi emits between the hook and tool execution carries the original args, not the patched ones. The tool itself runs with the patched args (verified) andtool_execution_endcarries the patched output. Embedders displaying tool calls in a UI should readpre_tool_use_response.tool_input(their own copy) or wait fortool_execution_end.
Composition (first-deny-wins)
options = OttoOptions(
pre_tool_use_hooks=[
PreToolUseHookEntry(matcher="webfetch", hook=url_allowlist),
PreToolUseHookEntry(matcher="bash", hook=command_filter),
PreToolUseHookEntry(matcher=is_gated, hook=approval_required),
],
)
See examples/pre_tool_use.py for the basic shape and
examples/approval_workflow.py for the polaris-style approve/reject/timeout
pattern.
Options
@dataclass
class OttoOptions:
otto_path: str | None = None # override binary location
cwd: str | None = None # defaults to os.getcwd()
env: dict[str, str | None] | None = None
provider: str | None = None # default "astronomer"
model: str | None = None # otto's current default if None
no_session: bool = False # skip ~/.astro/otto/sessions/ persistence
session_path: str | None = None # resume an existing .jsonl session (maps to --session)
thinking_level: ThinkingLevel | None = None # "off"|"minimal"|"low"|"medium"|"high"|"xhigh"
pre_tool_use_hooks: list[PreToolUseHookEntry] = []
hook_timeout_ms: int = 30_000 # default per-hook timeout
extra_args: list[str] = []
on_stderr: Callable[[str], None] | None = None
thinking_level is applied right after start(). You can also change it mid-session with await client.set_thinking_level(level).
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file astronomer_otto_sdk-0.0.4.tar.gz.
File metadata
- Download URL: astronomer_otto_sdk-0.0.4.tar.gz
- Upload date:
- Size: 20.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
100cbcbe7ba948e3d17255da1dcb17f1998b332eafd82ffbfc052512e71ec3ae
|
|
| MD5 |
caa9e0cc7be048cb24d2adb62eb266b2
|
|
| BLAKE2b-256 |
139b504c7c8112aed3752108baccc0842ba105efb376a9947b8822bbe8df7db1
|
File details
Details for the file astronomer_otto_sdk-0.0.4-py3-none-any.whl.
File metadata
- Download URL: astronomer_otto_sdk-0.0.4-py3-none-any.whl
- Upload date:
- Size: 23.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7806feadf73008adbb26647551dd8235fa65afc0aa98ffd28ae914b37f665ed2
|
|
| MD5 |
8db2e16ec8901928702ff194fd0cf615
|
|
| BLAKE2b-256 |
e002106d930a9ac267880e3cf70e7634f2d558fedcf807dfdae8fe5744c1042a
|