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 finalResult.- 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-callReadTracker. - Custom tools —
@tooldecorator 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>inOptions.toolsafter configuringOptions.mcp_servers. - Hooks — middleware-style interception around tool calls and turn boundaries:
PreToolUse,PostToolUse,Stop,SubagentStart,SubagentStop. Hooks canallow/deny/modifyinputs and outputs. - Permissions — four modes (
default,acceptEdits,bypassPermissions,plan) plus a pluggablecan_use_toolcallback. The default policy mirrors Claude Code's behavior (read-only inplan, ask onBashinacceptEdits, etc.). - Subagents — named
AgentDefinitions inOptions.agents. The model dispatches them through theTasktool; their events are forwarded into the parent's stream wrapped inSubagentStart/SubagentEnd. - Token budget guard — opt-in via
Options.context_window, stops the loop withstop_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
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 asextra_body.reasoning.max_tokens(oreffort: "high"for"adaptive"). - OpenAI reasoning models (
o1/o3/o4/gpt-5): mapped to a coarsereasoning_effortstring. - 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_turns—Options.max_turnsexceeded.tool_terminate— every runnable tool in a batch returnedterminate=True(a clean way for a tool to end the loop — e.g. a "submit answer" tool).context_full— token estimate exceededcontext_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
PermissionUpdatetype is wired but ignored). - A non-OpenRouter provider. The
Providerinterface 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
Release history Release notifications | RSS feed
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 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d8d200aef1dc4b63c59341195ceb79abdc06c8dea48f02e2a8b0501295c01a80
|
|
| MD5 |
5a92729b6a574bb7501fb2fecabd6e37
|
|
| BLAKE2b-256 |
30411122a176a89b8109adbe83d2fdeb3b666d5b5dc65cc0af7fa8e525616ea0
|
Provenance
The following attestation bundles were made for binho_sdk-0.2.0.tar.gz:
Publisher:
release.yml on unlegacy/binho-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
binho_sdk-0.2.0.tar.gz -
Subject digest:
d8d200aef1dc4b63c59341195ceb79abdc06c8dea48f02e2a8b0501295c01a80 - Sigstore transparency entry: 1524508900
- Sigstore integration time:
-
Permalink:
unlegacy/binho-sdk@b7df437b135a86628bffbcb25549d2fdeaaa0d84 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/unlegacy
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b7df437b135a86628bffbcb25549d2fdeaaa0d84 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74bfcf91ee281ce73357c0f78efe309debf187f39bc22039075b1a9ea9ab9c5e
|
|
| MD5 |
1b73606c75486e466df1f554f81a479d
|
|
| BLAKE2b-256 |
ff255ef7ca0defd24a4e0cb8e21bcc835101ea587f597f711c3c40241cbb7f6f
|
Provenance
The following attestation bundles were made for binho_sdk-0.2.0-py3-none-any.whl:
Publisher:
release.yml on unlegacy/binho-sdk
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
binho_sdk-0.2.0-py3-none-any.whl -
Subject digest:
74bfcf91ee281ce73357c0f78efe309debf187f39bc22039075b1a9ea9ab9c5e - Sigstore transparency entry: 1524508918
- Sigstore integration time:
-
Permalink:
unlegacy/binho-sdk@b7df437b135a86628bffbcb25549d2fdeaaa0d84 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/unlegacy
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b7df437b135a86628bffbcb25549d2fdeaaa0d84 -
Trigger Event:
push
-
Statement type: