Metadata-Version: 2.4
Name: binho-sdk
Version: 0.2.1
Summary: Python agent SDK with Claude Code-style loop semantics on top of OpenRouter, with MCP client support.
Project-URL: Homepage, https://github.com/unlegacy/binho-sdk
Project-URL: Repository, https://github.com/unlegacy/binho-sdk
Project-URL: Issues, https://github.com/unlegacy/binho-sdk/issues
Author-email: "Unlegacy, Inc." <zolin@unlegacy.ai>
License: MIT License
        
        Copyright (c) 2026 Unlegacy, Inc.
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: jsonschema>=4.21
Requires-Dist: openai>=1.50
Requires-Dist: pydantic>=2.6
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# binho-sdk

**A Python agent SDK with Claude Code-style loop semantics on top of OpenRouter, with first-class MCP support — developed by Unlegacy.**

Build agents that *think* like Claude Code — same turn loop, same tool dispatch model, same hooks and permission surface — but pointed at any of the 300+ models OpenRouter exposes (Claude, GPT, Gemini, Llama, DeepSeek, Qwen, …). The public API is a single `async def query() -> AsyncIterator[Event]`. No frameworks, no graphs, no DSL.

```python
import asyncio
from binho_sdk import query, Options

async def main():
    async for ev in query(
        prompt="Summarize what this repo does, then list the top 3 risks.",
        options=Options(
            model="anthropic/claude-sonnet-4-5",
            tools=["Read", "Glob", "Grep"],
        ),
    ):
        if ev.type == "text_delta":
            print(ev.delta, end="", flush=True)
        elif ev.type == "result":
            print(f"\n[{ev.usage.input_tokens} in / {ev.usage.output_tokens} out / ${ev.usage.cost_usd or 0:.4f}]")

asyncio.run(main())
```

---

## Why binho-sdk

The agent loop is small. Most "agent frameworks" bury that loop under graphs, state machines, decorator stacks, and routing layers you didn't ask for. The Anthropic [`claude-agent-sdk`](https://github.com/anthropics/claude-agent-sdk-python) gets the shape right but is locked to Anthropic's API.

`binho-sdk` keeps the shape, drops the lock-in:


|                     | `binho-sdk`                                                           | `claude-agent-sdk`                 | LangChain / LangGraph |
| ------------------- | --------------------------------------------------------------------- | ---------------------------------- | --------------------- |
| Models              | Any (via OpenRouter)                                                  | Anthropic only                     | Any                   |
| Loop control        | Explicit, in your process                                             | Hidden behind subprocess           | Graph DSL             |
| Streaming events    | Single async iterator                                                 | Single async iterator              | Callbacks             |
| Built-in tools      | `Read`/`Write`/`Edit`/`Glob`/`Grep`/`Bash`/`Task`                     | Same set                           | None                  |
| MCP client          | HTTP, tools-only                                                      | stdio + HTTP                       | Plugin                |
| Subagents           | First-class (`Task` tool, `AgentDefinition`)                          | First-class                        | Manual                |
| Hooks / permissions | `PreToolUse` / `PostToolUse` / `Stop` / etc., `can_use_tool` callback | Same                               | DIY                   |
| Footprint           | ~2k LOC, 4 deps                                                       | Heavier (subprocess + Node bridge) | Heavy                 |


If you've used Claude Code's hooks, permission modes, or subagent dispatch, the concepts here will be familiar — they're the same model, re-implemented as a clean Python library.

---

## Install

```bash
pip install binho-sdk
# or
uv add binho-sdk
```

Requires Python 3.11+. Set `OPENROUTER_API_KEY` in your environment, or pass `api_key=` to `Options`.

```bash
export OPENROUTER_API_KEY=sk-or-v1-...
```

---

## What it gives you

- **`query()` async generator** — stateless entry point. A single iterator yields every event the loop produces and exhausts after the final `Result`.
- **Event stream** — `TextDelta`, `ToolUse`, `ToolResult`, `TurnStart` / `TurnEnd`, `SubagentStart` / `SubagentEnd`, `Result`. Discriminated union keyed by `.type`.
- **Built-in tools** — `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash`, `Task` (subagent dispatcher). The read-before-edit invariant is enforced by a per-call `ReadTracker`.
- **Custom tools** — `@tool` decorator on an async function with a Pydantic input model; the JSON Schema is generated automatically.
- **MCP client** — HTTP transport, tools-only. Reference an MCP tool by `mcp__<server>__<tool>` in `Options.tools` after configuring `Options.mcp_servers`.
- **Hooks** — middleware-style interception around tool calls and turn boundaries: `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. Hooks can `allow` / `deny` / `modify` inputs and outputs.
- **Permissions** — four modes (`default`, `acceptEdits`, `bypassPermissions`, `plan`) plus a pluggable `can_use_tool` callback. The default policy mirrors Claude Code's behavior (read-only in `plan`, ask on `Bash` in `acceptEdits`, etc.).
- **Subagents** — named `AgentDefinition`s in `Options.agents`. The model dispatches them through the `Task` tool; their events are forwarded into the parent's stream wrapped in `SubagentStart` / `SubagentEnd`.
- **Token budget guard** — opt-in via `Options.context_window`, stops the loop with `stop_reason="context_full"` before you blow the model's context window.
- **OpenRouter routing** — `provider_routing`, `allow_provider_fallback`, `max_tokens` (to keep OpenRouter's per-request credit reservation small at high concurrency).

---

## Quickstart: a code-exploration agent with subagents and MCP

```python
import asyncio
from binho_sdk import query, Options, AgentDefinition, McpServerConfig

async def main():
    options = Options(
        model="anthropic/claude-sonnet-4-5",
        tools=["Read", "Grep", "Glob", "Task", "mcp__docs__bm25_search"],
        agents={
            "explorer": AgentDefinition(
                description="Investigates code, returns findings.",
                prompt="You are an exploration subagent. Read aggressively, summarize tightly.",
                tools=["Read", "Grep", "Glob", "mcp__docs__bm25_search"],
            ),
            "reviewer": AgentDefinition(
                description="Skeptically reviews findings.",
                prompt="You are a review subagent. Distrust the narrative.",
                tools=["Read", "Grep"],
                permission_mode="plan",
            ),
        },
        mcp_servers={
            "docs": McpServerConfig(
                url="https://example.com/mcp",
                headers={"Authorization": "Bearer ..."},
            ),
        },
        max_turns=30,
        context_window=200_000,
    )

    async for ev in query("Plan the migration of module X.", options):
        match ev.type:
            case "text_delta":
                print(ev.delta, end="", flush=True)
            case "tool_use":
                print(f"\n→ {ev.name}({ev.arguments})")
            case "subagent_start":
                print(f"\n┌── subagent {ev.agent_type}")
            case "subagent_end":
                print("\n└── done")
            case "result":
                print(f"\n[stop={ev.stop_reason} cost=${ev.usage.cost_usd or 0:.4f}]")

asyncio.run(main())
```

---

## Use cases

**Code agents.** `Read`/`Grep`/`Edit`/`Bash` plus a `Task` tool dispatching focused subagents is the same loop Claude Code uses. Point it at any model OpenRouter offers — useful for benchmarking, cost optimization, or running against a model that isn't Anthropic's.

**Research / RAG with MCP.** Plug in an MCP doc-search server, add `mcp__server__search` to your tools list, and the model has structured retrieval without you writing a retriever loop.

**CI / repo bots.** Run as a one-shot inside GitHub Actions: spawn `query()`, stream the result, post a comment. The single-async-iterator shape makes this trivial — no subprocess, no daemon.

**Doc & data pipelines.** Use the `Stop` hook plus the `tool_terminate` stop reason to wire the loop into longer pipelines that need an agent step in the middle.

**Custom shells / TUIs.** The event stream is everything you need to render a Claude Code-style UI on top: text deltas, tool calls, subagent envelopes, final result.

**Evaluations.** Inject a mock provider via the test seam (`_provider=`) and replay deterministic transcripts against your tool definitions.

---

## Custom tools

```python
from pydantic import BaseModel
from binho_sdk import tool, ToolContext, Options

class WeatherArgs(BaseModel):
    city: str

@tool(name="weather", description="Look up current weather for a city.")
async def weather(args: WeatherArgs, ctx: ToolContext) -> str:
    # ctx gives you cwd, options, the running message history, agent_id, etc.
    return f"Cloudy, 14°C in {args.city}"

# Pass the Tool directly:
options = Options(model="anthropic/claude-sonnet-4-5", tools=[weather])
```

---

## Hooks

```python
from binho_sdk import HookContext, HookResult

async def block_rm(ctx: HookContext) -> HookResult | None:
    if ctx.tool_name == "Bash" and "rm -rf" in (ctx.tool_input or {}).get("command", ""):
        return HookResult(decision="deny", reason="rm -rf is denied here")
    return None

options = Options(
    model="anthropic/claude-sonnet-4-5",
    tools=["Bash"],
    hooks={"PreToolUse": [block_rm]},
)
```

Five events: `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`. Each callback can return `None` (pass), `HookResult(decision="deny", ...)` (block), or `HookResult(decision="modify", modified_input=..., modified_output=...)` (rewrite the tool's input or output before it reaches the next hook / the loop).

---

## Permissions

```python
from binho_sdk import CanUseToolInput, PermissionResult

async def policy(inp: CanUseToolInput) -> PermissionResult:
    if inp.tool_name == "Bash" and "sudo" in str(inp.tool_input.get("command", "")):
        return PermissionResult(behavior="deny", message="no sudo")
    return PermissionResult(behavior="allow")

options = Options(model="...", tools=["Bash"], can_use_tool=policy)
```

Without a `can_use_tool` callback, a small default policy is applied per `permission_mode`:


| Mode                | Behavior                                                     |
| ------------------- | ------------------------------------------------------------ |
| `default`           | Allow all.                                                   |
| `plan`              | Allow `Read` / `Glob` / `Grep` only. Everything else denied. |
| `acceptEdits`       | Allow read+edit built-ins, ask on `Bash`, deny MCP.          |
| `bypassPermissions` | Allow all. Use only when you trust the input.                |


---

## Depth control

`query()` accepts optional kwargs that override `Options` defaults on a per-request basis. Use them to translate user intent into more or less model work without rebuilding the `Options` object:

```python
async for event in query(
    "Investigate the bug in src/foo.py",
    options=options,
    max_turns=100,                       # raise the loop ceiling for deep work
    system_prompt_append=(                # add extra instructions for this request
        "Investigate thoroughly. Prefer parallel tool calls."
    ),
    thinking_budget=16000,                # extended thinking on supported models
    max_tokens=8000,                      # output cap for this request
):
    ...
```

**`thinking_budget`** maps to the upstream provider's reasoning controls:
- Anthropic (`anthropic/*`): forwarded as `extra_body.reasoning.max_tokens` (or `effort: "high"` for `"adaptive"`).
- OpenAI reasoning models (`o1`/`o3`/`o4`/`gpt-5`): mapped to a coarse `reasoning_effort` string.
- Other models: silently ignored.

**`system_prompt_append`** is emitted as a *second* system message after the base `Options.system_prompt`. This keeps the base prompt byte-identical across requests so prompt caching stays warm.

**Prompt caching** is enabled by default for Anthropic models. The base system prompt and the tool schema array are marked with `cache_control: {"type": "ephemeral"}`. To opt out:

```python
options = Options(model="anthropic/claude-opus-4-7", enable_prompt_caching=False)
```

All four kwargs are optional and additive — existing call sites are unaffected.

---

## Stop reasons

`Result.stop_reason` is one of:

- `end_turn` — assistant returned no tool calls.
- `max_turns` — `Options.max_turns` exceeded.
- `tool_terminate` — every runnable tool in a batch returned `terminate=True` (a clean way for a tool to end the loop — e.g. a "submit answer" tool).
- `context_full` — token estimate exceeded `context_window * context_safety_margin`.

---

## Project status

`binho-sdk` is **0.1.x**. The public surface (`query`, `Options`, event types, `@tool`, hooks, permissions) is intended to be stable, but expect minor additive changes before 1.0. Breaking changes will be flagged in the changelog.

Not in v1:

- MCP stdio transport (HTTP only).
- Persistent permission allowlists (the `PermissionUpdate` type is wired but ignored).
- A non-OpenRouter provider. The `Provider` interface is small — adding a direct Anthropic / OpenAI / Bedrock provider is straightforward.

---

## Versioning

This project follows [SemVer](https://semver.org/). While the version is `0.x`, the public API may change in minor releases (`0.1 → 0.2`); patch releases (`0.1.0 → 0.1.1`) contain bug fixes only and remain API-compatible. Pin `binho-sdk>=0.1,<0.2` if you want patch updates without surprise. The version will move to `1.0.0` once the public surface is considered stable.

---

## Development

```bash
git clone https://github.com/unlegacy/binho-sdk
cd binho-sdk
uv sync --extra dev
uv run pytest
uv run ruff check
uv run mypy src
```

Tests marked `smoke` hit the real OpenRouter API and require `OPENROUTER_API_KEY`; they are skipped by default. Run `pytest -m smoke` to include them.

---

## License

MIT. See [LICENSE](LICENSE).
