Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.qoder.com/llms.txt

Use this file to discover all available pages before exploring further.

Tools are capabilities the model can call while executing a task. The Python edition of Qoder Agent SDK supports two kinds of tools:
  • Built-in tools: Provided by Qoder CLI, such as reading files, searching, executing commands, and invoking subagents.
  • Custom tools: Defined by SDK users through @tool() and create_sdk_mcp_server() as Python functions and exposed to the model via an in-process MCP server.
This guide focuses on defining custom tools. For more MCP server integration methods, see MCP Integration. For the complete permissions reference, see Permissions.

Built-in Tools

When using built-in tools, you do not implement the tools yourself. You only control which tools the current session sees, which tools are pre-authorized, and which tools are denied through QoderAgentOptions.
import asyncio

from qoder_agent_sdk import QoderAgentOptions, qodercli_auth, query


async def main():
    options = QoderAgentOptions(
        auth=qodercli_auth(),
        cwd="/path/to/project",
        tools=["Read", "Grep", "Glob"],
        allowed_tools=["Read", "Grep", "Glob"],
    )

    async for message in query(
        prompt=(
            "Read this repository and summarize risks in the authentication "
            "module. Do not modify files."
        ),
        options=options,
    ):
        print(message)


asyncio.run(main())
Common built-in tools include Read, Edit, Write, Bash, Glob, Grep, WebFetch, WebSearch, and Agent. Tool names are determined by the underlying Qoder CLI; in permission configuration, use the names the CLI exposes to the model — for example Read, Write, Bash. For the complete built-in tool list, see Tools Reference - Built-in Tool List.

Custom Tools

Define a custom tool when you want the model to call your own business capability — for example, querying orders, searching an internal knowledge base, calling an approval system, or accessing a read-only database. Python custom tools usually involve three steps:
  1. Decorate an async def handler with @tool().
  2. Register one or more tools to an in-process MCP server with create_sdk_mcp_server().
  3. Attach via mcp_servers in QoderAgentOptions, and control calls with permission settings.

Custom Tool Integration Steps

First, here is a complete minimal example. The following sections then explain what each of the three steps can configure:
import asyncio
import json

from mcp.types import ToolAnnotations

from qoder_agent_sdk import (
    QoderAgentOptions,
    create_sdk_mcp_server,
    qodercli_auth,
    query,
    tool,
)


orders = {
    "O-1001": {"order_id": "O-1001", "status": "shipped", "eta": "2026-05-20"},
}


@tool(
    "lookup_order",
    "Look up an order by order ID and return its status as JSON.",
    {"order_id": str},
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def lookup_order(args):
    order_id = args["order_id"]
    order = orders.get(order_id)

    if order is None:
        return {
            "is_error": True,
            "content": [{"type": "text", "text": f"Order not found: {order_id}"}],
        }

    return {"content": [{"type": "text", "text": json.dumps(order)}]}


order_tools = create_sdk_mcp_server(
    name="orders",
    tools=[lookup_order],
)


async def main():
    options = QoderAgentOptions(
        auth=qodercli_auth(),
        mcp_servers={"orders": order_tools},
        allowed_tools=["mcp__orders__lookup_order"],
    )

    async for message in query(
        prompt="Check the status of order O-1001 and summarize it in one sentence.",
        options=options,
    ):
        print(message)


asyncio.run(main())

Step 1: Create a Tool with @tool()

This step defines the tool itself: its name, description, input parameters, execution logic, and metadata.

@tool() Arguments

@tool() is a decorator. It has 4 arguments:
def tool(
    name: str,
    description: str,
    input_schema: type | dict[str, Any],
    annotations: ToolAnnotations | None = None,
): ...
ArgumentTypeRequiredMeaning
namestrYesUnique tool identifier within the current MCP server
descriptionstrYesTool description for the model; explain when to use it, what it does, and what it returns
input_schematype | dict[str, Any]YesDefines tool input parameters; supports simple dict, TypedDict, and full JSON Schema dict
annotationsToolAnnotations | NoneNoMCP tool annotations such as readOnlyHint, destructiveHint, openWorldHint
For the complete API signature and the returned SdkMcpTool type, see Tools Reference - tool(). The tool handler must be an async function and typically receives an args dict:
@tool("search_docs", "Search internal product documentation.", {"query": str})
async def search_docs(args):
    return {"content": [{"type": "text", "text": f"Searching: {args['query']}"}]}
If the handler declares a second positional parameter, the SDK passes a ToolInvocationContext. Its signal is an asyncio.Event that is set when the CLI cancels the current tool call — useful for long tasks to bail out cooperatively:
@tool("watch", "Watch a counter until max.", {"max": int})
async def watch(args, extra):
    for i in range(args["max"]):
        if extra.signal.is_set():
            return {"content": [{"type": "text", "text": f"aborted at {i}"}]}
        await asyncio.sleep(0.01)

    return {"content": [{"type": "text", "text": "done"}]}

Configure Input Parameters

The Python edition’s input_schema supports three forms. They are all normalized to MCP’s JSON Schema. Form 1: simple dict Suitable for a few simple parameters. Dict keys are parameter names and values are Python types; with this form, all keys are required.
input_schema = {
    "query": str,
    "max_results": int,
    "include_archived": bool,
}
You can use typing.Annotated to attach descriptions to fields:
from typing import Annotated


input_schema = {
    "query": Annotated[str, "Search keywords"],
    "max_results": Annotated[int, "Maximum number of snippets to return"],
}
Common types are translated to JSON Schema:
Python formJSON Schema meaning
str{"type": "string"}
int{"type": "integer"}
float{"type": "number"}
bool{"type": "boolean"}
list[str]String array
dictObject
Annotated[T, "..."]Adds description to T’s schema
Form 2: TypedDict Suitable when there are many fields, optional fields are needed, or you want to reuse type definitions. Use NotRequired for optional fields:
from typing import Annotated, TypedDict

from typing_extensions import NotRequired


class SearchInput(TypedDict):
    query: Annotated[str, "Search keywords"]
    max_results: NotRequired[Annotated[int, "Maximum snippets to return"]]


@tool("search_docs", "Search internal product documentation.", SearchInput)
async def search_docs(args):
    limit = args.get("max_results", 5)
    return {"content": [{"type": "text", "text": f"{args['query']} ({limit})"}]}
Python 3.11+ can import NotRequired directly from typing; Python 3.10 needs to import it from typing_extensions. Form 3: full JSON Schema dict Use a full JSON Schema when you need enums, numeric ranges, string format constraints, or nested objects:
input_schema = {
    "type": "object",
    "properties": {
        "query": {"type": "string", "description": "Search keywords"},
        "source": {
            "type": "string",
            "enum": ["docs", "tickets", "wiki"],
            "description": "Where to search",
        },
        "max_results": {"type": "integer", "minimum": 1, "maximum": 10},
    },
    "required": ["query"],
}
About optional parameters: with the simple-dict form, all fields are required. To express optional fields, prefer TypedDict + NotRequired or the required list of a full JSON Schema.

Configure Tool Metadata

annotations uses mcp.types.ToolAnnotations. The SDK puts it on the MCP tool definition; the CLI can use it for scheduling, permissions, or status display.
from mcp.types import ToolAnnotations


@tool(
    "search_docs",
    "Search internal product documentation.",
    {"query": str},
    annotations=ToolAnnotations(
        title="Search docs",
        readOnlyHint=True,
        destructiveHint=False,
        openWorldHint=False,
    ),
)
async def search_docs(args):
    return {"content": [{"type": "text", "text": args["query"]}]}
Common fields:
FieldTypeMeaning
titlestrHuman-readable title for the tool
readOnlyHintboolMarks the tool as read-only and not modifying any state
destructiveHintboolMarks that the tool may modify or delete data
openWorldHintboolMarks that the tool accesses external systems or networks
maxResultSizeCharsintThe Python SDK passes this to the CLI via _meta["anthropic/maxResultSizeChars"] to relax the tool result length limit
Note: annotations do not replace permission configuration. Whether a tool call is allowed is still determined by tools, allowed_tools, disallowed_tools, permission_mode, can_use_tool, and hooks. The annotations field names echoed in get_mcp_status() / MCP status may also be the CLI-projected readOnly, destructive, openWorld, rather than the original MCP *Hint names.

Step 2: Register with an MCP Server

create_sdk_mcp_server() registers one or more tools as an in-process MCP server. The server name becomes part of the full tool name, so keep it short and stable.
kb_tools = create_sdk_mcp_server(
    name="kb",
    version="1.0.0",
    tools=[search_docs],
)
FieldHow to set itDescription
nameFor example kb, ordersServer name; forms full tool names like mcp__{name}__{tool}
versionFor example "1.0.0"Informational version, defaults to "1.0.0"
tools[search_docs, lookup_order]Tools registered to this server
The return value is McpSdkServerConfig, which can be passed directly to QoderAgentOptions.mcp_servers. For the complete return type, see Tools Reference - create_sdk_mcp_server(). create_sdk_mcp_server() performs synchronous validation:
  • The server name must be a non-empty string.
  • Tool names must be non-empty strings.
  • Tool descriptions must be non-empty strings.
  • The same server cannot have duplicate tool names.

Step 3: Attach to query()

After you put the server in options.mcp_servers, the CLI discovers its tools and calls back into your handler through the SDK when the model needs them.
options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"kb": kb_tools},
    allowed_tools=["mcp__kb__search_docs"],
)

async for message in query(
    prompt="Search docs for the refund policy and summarize it.",
    options=options,
):
    print(message)
The full custom tool name format is:
mcp__{serverName}__{toolName}
For example, if the server name is orders and the tool name is lookup_order, the full name is mcp__orders__lookup_order. Use this full name in allowed_tools, disallowed_tools, can_use_tool, hook matchers, and subagent tools configuration. QoderSDKClient multi-turn sessions use the same mcp_servers configuration:
from qoder_agent_sdk import QoderSDKClient


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"kb": kb_tools},
    allowed_tools=["mcp__kb__search_docs"],
)

async with QoderSDKClient(options=options) as client:
    await client.query("Search docs for the refund policy.")

    async for message in client.receive_response():
        print(message)

Controlling Tool Permissions

When the model calls tools, the SDK provides multiple permission layers. You can decide:
  • Which tools are provided to the current session.
  • Which tools are allowed by default.
  • Which tools are explicitly denied.
  • Whether the host application makes a dynamic decision before each tool call.

Permission Control Overview

MethodPurposeGranularityUse case
toolsLimits the visible tool set for this sessionSessionNarrow the tools the model can see at the source
allowed_tools / disallowed_toolsPre-authorizes or denies specific toolsToolYou know exactly which tools to allow or deny
permission_modeSets the default permission policy for the whole sessionGlobalQuickly switch plan mode, accept edits, or skip permissions
can_use_toolRuns custom logic before each callCallDecide dynamically based on argument content
hooks["PreToolUse"]Intercepts tool calls through the hooks lifecycleCallYou already use the hooks system and want shared auditing or blocking
These methods can be combined. A common pattern is to use tools to narrow the visible set, allowed_tools / disallowed_tools for static rules, and can_use_tool for argument-level decisions.

Method 1: tools, allowed_tools, disallowed_tools

tools controls the tools visible to this session. allowed_tools and disallowed_tools control permission rules. Custom MCP tools must use full names.
# Only expose read/search tools to this session.
QoderAgentOptions(
    tools=["Read", "Glob", "Grep"],
    allowed_tools=["Read", "Glob", "Grep"],
)

# Explicitly deny high-risk tools.
QoderAgentOptions(
    disallowed_tools=["Bash", "Write", "Edit"],
)

# Use full names for custom MCP tools.
QoderAgentOptions(
    mcp_servers={"orders": order_tools},
    allowed_tools=["mcp__orders__lookup_order"],
)

# Disable all tools. The model can only answer from its context.
QoderAgentOptions(tools=[])
When the same tool matches both allow and deny rules, the deny rule takes precedence.

Method 2: permission_mode

permission_mode sets the default permission behavior for the whole session with a single line.
QoderAgentOptions(
    permission_mode="acceptEdits",
)
ModeEffect
"default"Standard permission behavior; sensitive operations are handled by rules or runtime policy
"acceptEdits"Automatically accepts file edit operations; other sensitive operations still follow the permission policy
"bypassPermissions"Skips permission checks; must also set allow_dangerously_skip_permissions=True
"yolo"Compatibility alias for "bypassPermissions"; must also set allow_dangerously_skip_permissions=True
"plan"Plan mode, suitable for asking the model to produce a plan first
"dontAsk"Does not ask interactively; operations that are not pre-authorized or allowed by rules are denied
"auto"Runtime capability decides allow or deny automatically

Method 3: can_use_tool

can_use_tool runs before a tool call. You can allow or deny based on the tool name, arguments, and approval context.
from typing import Any

from qoder_agent_sdk import (
    PermissionResultAllow,
    PermissionResultDeny,
    ToolPermissionContext,
)


async def can_use_tool(
    tool_name: str,
    input_data: dict[str, Any],
    context: ToolPermissionContext,
):
    if tool_name != "mcp__orders__lookup_order":
        return PermissionResultDeny(
            message="Only order lookup is allowed in this workflow.",
        )

    return PermissionResultAllow(updated_input=input_data)


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"orders": order_tools},
    allowed_tools=["mcp__orders__lookup_order"],
    can_use_tool=can_use_tool,
)
Common return values:
ReturnEffect
PermissionResultAllow()Allows execution with the original arguments
PermissionResultAllow(updated_input={...})Allows execution and replaces tool arguments
PermissionResultDeny(message="reason")Denies execution; the model can see the reason and try another way
PermissionResultDeny(message="reason", interrupt=True)Denies and interrupts the current agent loop
Common fields on ToolPermissionContext include tool_use_id, agent_id, signal, title, display_name, description, suggestions, blocked_path, and decision_reason. For a more complete permission strategy, see Permissions. When using custom tools in a subagent, also use the full tool name:
from qoder_agent_sdk import AgentDefinition


options = QoderAgentOptions(
    auth=qodercli_auth(),
    mcp_servers={"orders": order_tools},
    allowed_tools=["Agent"],
    agents={
        "order-support": AgentDefinition(
            description="Handles order lookup and explains order status.",
            prompt="Use order tools to answer order status questions clearly.",
            tools=["mcp__orders__lookup_order"],
        ),
    },
)

Method 4: hooks["PreToolUse"]

If you already use the hooks system, use PreToolUse to intercept or audit tool calls in one place.
from qoder_agent_sdk import HookMatcher


async def block_dangerous_bash(inp, tool_use_id, context):
    command = inp.get("tool_input", {}).get("command", "")
    if "rm -rf" in command:
        return {
            "hookSpecificOutput": {
                "hookEventName": "PreToolUse",
                "permissionDecision": "deny",
                "permissionDecisionReason": "rm -rf is not allowed",
            }
        }

    return {
        "hookSpecificOutput": {
            "hookEventName": "PreToolUse",
            "permissionDecision": "allow",
        }
    }


options = QoderAgentOptions(
    allowed_tools=["Bash"],
    hooks={
        "PreToolUse": [
            HookMatcher(matcher="Bash", hooks=[block_dangerous_bash]),
        ],
    },
)
PreToolUse’s permissionDecision can be "allow", "deny", "ask", or "defer".

How the SDK Handles Tool Errors

Tool handlers have three error paths.

Business Failure: Return is_error: True

For expected business failures, return is_error: True. The SDK converts the result into an MCP CallToolResult and passes it to the CLI. The model can see the failure content and may retry or choose another path.
return {
    "is_error": True,
    "content": [
        {
            "type": "text",
            "text": json.dumps(
                {
                    "error": "VALIDATION_ERROR",
                    "message": "Only SELECT statements are allowed.",
                }
            ),
        }
    ],
}
Good cases for is_error: True:
  • Arguments are valid but no business result exists, such as an order not found.
  • A security policy rejects execution, such as only allowing SELECT queries.
  • An external service returns a business error that can be explained.

Unexpected Exception: Handler Throws

If the handler raises an exception, the MCP layer converts it into an error result; the agent loop does not crash because of an ordinary tool exception. However, the model usually only sees the exception message — its format and content are less controllable than explicitly returning is_error: True.
@tool("fetch_user", "Fetch a user by ID.", {"user_id": str})
async def fetch_user(args):
    response = await user_service.fetch(args["user_id"])
    if not response.ok:
        raise RuntimeError("User service failed")

    return {"content": [{"type": "text", "text": await response.text()}]}
Recommendation: use is_error: True for predictable business failures, and raise only for truly unexpected exceptions.

Malformed Returns: SDK Wraps as Errors

The Python SDK performs a runtime fallback check on handler return values:
  • Returning None: converted to error text explaining that the handler must return a dict containing "content".
  • Returning a non-dict (e.g., string, number, list): converted to text content and marked isError=True.
  • Returning a dict but without "content": converted to error text and lists the actual keys.
  • Returning unsupported content types: that content block is skipped and a warning is logged.
These fallbacks prevent the model from seeing an empty success result, but documentation and business code should still always return the standard structure.

Tool Return Values

A tool handler returns a dict, which the SDK converts into MCP’s CallToolResult. Text content is the most common:
return {
    "content": [{"type": "text", "text": "done"}],
}
You can also return structured JSON strings, which help the model understand and continue processing:
return {
    "content": [
        {
            "type": "text",
            "text": json.dumps(
                {
                    "order_id": "O-1001",
                    "status": "shipped",
                    "eta": "2026-05-20",
                }
            ),
        }
    ],
}
Common content blocks:
TypeShapePython SDK behavior
Text{"type": "text", "text": "..."}Converted to TextContent
Image{"type": "image", "data": "...", "mimeType": "image/png"}Converted to ImageContent; data is base64
Resource link{"type": "resource_link", "uri": "...", "name": "...", "description": "..."}Degrades to text, concatenating name / uri / description for the model
Embedded text resource{"type": "resource", "resource": {"text": "..."}}Converted to TextContent
The Python edition has two result differences worth noting:
  • The handler-returned dict’s top-level _meta is not propagated to CallToolResult.
  • When the handler indicates an error, use the Python field name "is_error": True, not the MCP/TypeScript-style isError. The SDK maps it to the MCP result internally.

Common Pitfalls

  • When writing permission configuration for custom tools, use the full mcp__server__tool name.
  • All fields in a Python simple-dict schema are required; use TypedDict + NotRequired or a full JSON Schema for optional fields.
  • Use a full JSON Schema dict when you need enums, numeric ranges, nested objects, or string pattern/format.
  • Handlers must be async def and return a dict with a "content" list.
  • Tool descriptions should explain “when to use it, what it does, what it returns” — avoid vague descriptions like query or helper.
  • readOnlyHint is tool metadata and a scheduling/permission hint, not a permission switch; whether execution is allowed is still determined by permission configuration.
  • Avoid putting a huge all-purpose business entry point into one universal tool. A tool should complete one clear class of action.

Continue Reading

  • MCP Integration: in-process, stdio, SSE, HTTP, OAuth, and other MCP server integration methods.
  • Permissions: permission_mode, allowed_tools, can_use_tool, hooks, and permission rule updates.
  • Hooks: PreToolUse, PostToolUse, PermissionRequest, and other lifecycle extensions.
  • Subagent Guide: Let different agents use different tool sets.