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)
Structured output
Pass output_schema (a plain JSON Schema dict; for pydantic use
MyModel.model_json_schema()) and otto registers a synthetic
submit_final_answer tool with that schema, instructs the agent to deliver its
final result through it, and nudges the model (bounded retries) if it tries to
finish without a valid call. run_query() extracts and re-validates the result:
from otto_sdk import QueryOptions, run_query
result = await run_query(QueryOptions(
prompt="diagnose the failing DAG",
cwd="./project",
output_schema={
"type": "object",
"properties": {
"diagnosis": {"type": "string"},
"severity": {"type": "string", "enum": ["low", "medium", "high"]},
},
"required": ["diagnosis", "severity"],
"additionalProperties": False,
},
))
match result.structured_output_status:
case "produced": # final_structured_output is schema-valid — use it
print(result.final_structured_output)
case "invalid": # extracted but failed validation
print(result.structured_output_errors)
case "missing": # run ended without a valid submit_final_answer call
...
case "not_requested": # no output_schema was set
...
- On Anthropic models, the retry after a nudge is API-forced via
tool_choice— the model cannot answer the recovery turn with prose. Additionally, withOTTO_STRICT_TOOL_USE=1in the environment, eligible models (Sonnet 4.5+/5.x, Haiku 4.5+) register the tool withstrict: true(grammar-constrained decoding — args schema-valid by construction). Strict is opt-in until the Astronomer gateway forwards strict schemas correctly. structured_output_statusdistinguishes "the model never delivered" ("missing") from "delivered but not schema-valid" ("invalid") — before this field, both looked likefinal_structured_output is Noneor required callers to hand-check the shape.- Validation runs SDK-side with jsonschema against your original schema — defense in depth on top of otto's own arg validation.
- When the run gives up, otto also emits a custom message event with
customType == STRUCTURED_OUTPUT_MISSING_CUSTOM_TYPEwhosedetailscarry diagnostics (reason, attempt counts, last validation error) — useful for logging;structured_output_statusdoes not depend on it. - CLI parity:
otto --mode json --output-schema ...exits4when the run completes without structured output.
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_end— one low-level run.agent_endcarrieswillRetry: truewhen pi is about to retry a failed provider call, so it does not mean the turn is over.agent_settled— the turn is over: pi will not retry or continue on its own.events()ends here. Needs otto v0.1.19 or later.turn_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-anthropic"
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).
Metadata
Release files for astronomer-otto-sdk 0.0.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| astronomer_otto_sdk-0.0.7.tar.gz | 23.6 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| astronomer_otto_sdk-0.0.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 51.2 kB
Release files / astronomer_otto_sdk-0.0.7.tar.gz
| Download URL | astronomer_otto_sdk-0.0.7.tar.gz |
|---|---|
| Size | 23.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
50b43a6f86ffaa27f4180862637342689621f86f3eac5b40d74977a1c53f2485
|
|
BLAKE2b-256 checksum How to use checksums |
194dc09a867aff39d0d6d250ce4842a60d8163bb3b733225c9fd18a1960872f2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Release files / astronomer_otto_sdk-0.0.7-py3-none-any.whl
| Download URL | astronomer_otto_sdk-0.0.7-py3-none-any.whl |
|---|---|
| Size | 27.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
56f538cdf6508a0b848e602c05fce8092275efd88e8337987eb87cd9e9b153f9
|
|
BLAKE2b-256 checksum How to use checksums |
f2acce472e4f13ac3bd22cb48b4a4265d6df846b09d1e72c32e0b4a091564c15
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is 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}
|
Security for astronomer-otto-sdk 0.0.7
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.