Skip to main content

Python agent SDK with Claude Code-style loop semantics on top of OpenRouter, with MCP client support.

Project description

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.

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 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

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.

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 streamTextDelta, ToolUse, ToolResult, TurnStart / TurnEnd, SubagentStart / SubagentEnd, Result. Discriminated union keyed by .type.
  • Built-in toolsRead, 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 AgentDefinitions 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 routingprovider_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

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

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

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

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:

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:

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_turnsOptions.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. 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

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.

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

binho_sdk-0.2.0.tar.gz (70.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

binho_sdk-0.2.0-py3-none-any.whl (49.2 kB view details)

Uploaded Python 3

File details

Details for the file binho_sdk-0.2.0.tar.gz.

File metadata

  • Download URL: binho_sdk-0.2.0.tar.gz
  • Upload date:
  • Size: 70.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for binho_sdk-0.2.0.tar.gz
Algorithm Hash digest
SHA256 d8d200aef1dc4b63c59341195ceb79abdc06c8dea48f02e2a8b0501295c01a80
MD5 5a92729b6a574bb7501fb2fecabd6e37
BLAKE2b-256 30411122a176a89b8109adbe83d2fdeb3b666d5b5dc65cc0af7fa8e525616ea0

See more details on using hashes here.

Provenance

The following attestation bundles were made for binho_sdk-0.2.0.tar.gz:

Publisher: release.yml on unlegacy/binho-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file binho_sdk-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: binho_sdk-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 49.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for binho_sdk-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 74bfcf91ee281ce73357c0f78efe309debf187f39bc22039075b1a9ea9ab9c5e
MD5 1b73606c75486e466df1f554f81a479d
BLAKE2b-256 ff255ef7ca0defd24a4e0cb8e21bcc835101ea587f597f711c3c40241cbb7f6f

See more details on using hashes here.

Provenance

The following attestation bundles were made for binho_sdk-0.2.0-py3-none-any.whl:

Publisher: release.yml on unlegacy/binho-sdk

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page