Skip to content

Tools

Tool system for AI function calling. See the Tool Calling & Policies guide and MCP Tool Provider guide for usage examples.

ToolPolicy

Bases: BaseModel

Per-agent allow/deny rules for tool access.

Rules use :func:fnmatch.fnmatch glob patterns (e.g. "mcp_*", "search_*").

Resolution order:

  1. Empty allow and empty denypermit all (backward compatible).
  2. If the tool name matches any deny pattern → blocked.
  3. If allow is non-empty and the tool name matches no allow pattern → blocked.
  4. Otherwise → permitted.

In short: deny always wins, and a non-empty allow list is a whitelist.

Role overrides ~~~~~~~~~~~~~~

role_overrides maps :class:~roomkit.models.enums.ParticipantRole string values (e.g. "observer", "member") to :class:RoleOverride instances.

Call :meth:resolve with a role to obtain an effective ToolPolicy that merges the base rules with the role-specific override.

resolve

resolve(role=None)

Return an effective ToolPolicy for the given role.

If role is None or has no override entry, returns self unchanged (backward compatible).

is_allowed

is_allowed(tool_name)

Return True if tool_name passes the policy.

as_filter

as_filter()

Return a callable (tool_name) -> bool suitable for :func:filter.

RoleOverride

Bases: BaseModel

Per-role tool policy override.

mode controls how the override combines with the base policy:

  • "restrict" (default): deny lists are unioned, allow lists are intersected (a tool must pass both the base and override allow lists).
  • "replace": the override completely replaces the base policy.

MCPToolProvider

MCPToolProvider(url, *, transport='streamable_http', tool_filter=None, headers=None)

Discover and invoke tools from an MCP server.

Supports both streamable_http (default) and sse transports.

Usage::

async with MCPToolProvider.from_url("http://localhost:8000/mcp") as mcp:
    tools = mcp.get_tools()          # list[AITool]
    handler = mcp.as_tool_handler()   # ToolHandler for AIChannel

tool_names property

tool_names

Return the names of all discovered tools.

from_url classmethod

from_url(url, *, transport='streamable_http', tool_filter=None, headers=None)

Create an MCPToolProvider for the given URL.

The provider is not connected until used as an async context manager.

Parameters:

Name Type Description Default
url str

MCP server URL.

required
transport str

"streamable_http" (default) or "sse".

'streamable_http'
tool_filter Callable[[str], bool] | None

Optional predicate to include only matching tool names.

None
headers dict[str, str] | None

Optional HTTP headers sent with every request.

None

Returns:

Type Description
MCPToolProvider

An MCPToolProvider instance (not yet connected).

get_tools

get_tools()

Return discovered tools as RoomKit AITool instances.

get_tools_as_dicts

get_tools_as_dicts()

Return discovered tools as plain dicts (for binding metadata).

call_tool async

call_tool(name, arguments, *, timeout=30.0)

Call a tool on the MCP server and return the result as a string.

Parameters:

Name Type Description Default
name str

Tool name.

required
arguments dict[str, Any]

Tool arguments dict.

required
timeout float

Maximum seconds to wait for a response.

30.0

Returns:

Type Description
str

Result string. Single TextContent → plain text; multi-part → JSON array;

str

error results → {"error": "..."}.

as_tool_handler

as_tool_handler()

Return a ToolHandler suitable for AIChannel(tool_handler=...).

Unknown tools (not from this MCP server) return {"error": "Unknown tool: <name>"}, which allows composition via compose_tool_handlers.

compose_tool_handlers

compose_tool_handlers(*handlers)

Chain multiple ToolHandlers so the first one that handles a tool wins.

Each handler is tried in order. If a handler returns a JSON object with {"error": "Unknown tool: ..."} the next handler is tried. The last handler's result is always returned as-is (even if it's an unknown-tool error).

Parameters:

Name Type Description Default
*handlers ToolHandler

Two or more ToolHandler callables.

()

Returns:

Type Description
ToolHandler

A single ToolHandler that dispatches to the first matching handler.

Raises:

Type Description
ValueError

If fewer than two handlers are provided.

Per-Call Context

A tool handler receives only (name, arguments). These accessors read the turn it is running under from a contextvar — see What a handler knows about the call.

current_tool_room_id

current_tool_room_id()

Room id of the tool loop the caller is executing under.

Returns None when called outside a tool loop.

current_tool_actor_id

current_tool_actor_id()

Participant id of whoever's turn the caller is executing under.

The author of the event that woke the channel this round. Read it rather than the identity a handler captured when it was built — one channel object serves every room and every speaker, so a captured identity is whoever happened to attach it.

It names the turn; it does not authenticate it. The value is a room Participant.id, and the inbound pipeline only substitutes the resolved Identity.id for it once identification succeeds — a turn still pending, ambiguous or unknown carries whatever the channel supplied, or a synthetic pending-…, and reads back just as non-None. A handler that reaches a person's data with it resolves it first: load the participant, require Participant.identification to be IDENTIFIED, and take Participant.identity_id as the principal.

The author need not be human, either. In a multi-agent room the waking event may be another agent's, whose participant id reads back the same way — compare the participant's role against ParticipantRole.AGENT when that distinction matters.

None outside a tool loop, and None when the turn has no participant behind it (a system injection, a webhook, a scheduled run). A caller that needs a person then decides for itself — refuse, or fall back to a principal it configured on purpose — rather than borrow whoever spoke last.

current_tool_allowed_names

current_tool_allowed_names()

Names of every tool in the current turn's resolved toolset.

_build_context stamps the turn's full toolset (config-provider result plus channel-injected tools) into the loop context; a host's tool handler can validate an incoming call against it instead of an attach-time snapshot that goes stale on shared channels. Includes skill-gated tools whose visibility is filtered per round — gating is presentation, not an execution boundary.

Returns None outside a tool loop or before context build, so hosts can fall back to their own allowlist.

current_tool_call

current_tool_call()

The per-call context of the tool call the caller is executing under.

What _run_one set before invoking the handler — the call's id, its room, its channel — and the reverse channel the handler may fill: structured_content, the MCP structured result the tool-call events carry verbatim for UI surfaces. A host that rewrites a result before the model reads it (a provider's private address turned into its own relay link, say) reaches the structured copy here, so the persisted event does not keep what the text no longer says.

None outside a tool call.

ToolCallContext dataclass

ToolCallContext(room_id='', tool_call_id='', channel_id='', structured_content=None)

Contextvar payload carrying tool-call metadata.

The ToolHandler protocol is (name, arguments) → str — it does not receive room_id, tool_call_id or channel_id. This payload bridges the gap: _ai_tools._run_one() sets it before calling the handler, and a handler that needs the call's origin reads it. Safe with :func:asyncio.gather, which creates Tasks with copied contexts.

structured_content is the reverse channel: the ToolHandler contract returns only a string, but MCP tools can produce a structured result (CallToolResult.structuredContent) that UI surfaces need verbatim — the LLM-facing string may be truncated/evicted when large. A handler that has one sets it here; _run_one() reads it back after the call and carries it on the tool-call events untouched by eviction.

current_response_metadata

current_response_metadata()

The response-metadata record of the turn the caller is executing under.

The one mapping RoomKit merges into every MESSAGE event the turn produces (see :mod:roomkit.models.response_metadata): a memory provider writing it during context build, a BEFORE_AI_GENERATION hook writing event.ai_context.response_metadata and a tool handler writing here all reach the same object. A tool handler is the case this exists for — the ToolHandler protocol hands it nothing but (name, arguments), and a document it read is a fact about the turn, not about the tool's string result.

Returns None when no loop context is set (a realtime pipeline, a direct call): the caller then has nothing to attribute to, and writes nothing. A loop started without a turn — no handle_event above it — carries a record of its own that no MESSAGE event is built from; writes to it are harmless and go nowhere.

Turn Response Metadata

The record every writer of a turn shares — see What a handler can tell the turn.

ResponseMetadata

ResponseMetadata(initial=None)

Bases: MutableMapping[str, Any]

Turn-level metadata merged into every MESSAGE event the turn produces.

Behaves as a dict for every reader and writer ([...], .get, .update, **, dict(...), ==); the only thing it adds is that Pydantic keeps the instance instead of copying it, so one turn has one record. A bare mapping passed where this type is expected is wrapped — the caller's dict is then a snapshot, which is what passing a literal means.

Each MESSAGE event carries the record as it stands when the event is created: a streamed segment persisted before a tool round shows what was known then, the final answer shows everything the turn learned; the non-streaming path builds all its events at the end, so they read alike.

coerce classmethod

coerce(value)

The instance itself, or a bare mapping wrapped — anything else is refused.

Human-in-the-Loop

HumanInputHandler

HumanInputHandler(*, retention=128)

Manages pending human input requests.

Core lifecycle::

pending = await handler.create("AskUser", args, room_id="r1", ...)
# → request is answerable from here on; the
#   ON_USER_INPUT_REQUIRED notification runs alongside
result  = await handler.wait(pending.pending_id, timeout=300)
# → blocks until resolve() / reject() / timeout

Two invariants the caller can rely on:

  • The notification never gates the answer. create() arms the request and returns; the _on_input_required callback runs in a background task. A human who answers while that callback is still running — a slow WebSocket broadcast, a hook burning its 30 s budget — is answering a request that is already listening. A denial coming back from the callback rejects the request, and wait() reports it.
  • A recorded outcome stays readable. A request settling — answered, rejected, timed out — is kept in a bounded retention (retention entries, newest kept), and wait() replays it once the request has left the active set. Only a genuinely unknown id raises ValueError, so neither a second read nor a host that keeps its own bookkeeping can turn an answer that arrived into a hard failure.
  • A channel scope belongs to a channel object, not to its id. A host that rebuilds the channel serving an id — the same agent re-attached to a second room — hands the same shared handler a succession of owners. Registering re-opens the scope, and the departing owner's close() is a no-op once a newer one has taken over, so a predecessor being torn down cannot silence its live successor.

The _on_input_required callback is injected by the framework (via register_channel hook builder) or set by the application directly.

pending property

pending

Active pending requests (read-only snapshot).

close async

close(*, channel_id=None, registration=None)

Stop notifications and settle requests owned by one channel.

When channel_id is omitted, all work owned by this handler is stopped. Channel-scoped closing lets a handler be shared safely by multiple :class:~roomkit.channels.ai.AIChannel instances.

registration is the token :meth:_set_on_input_required handed the closing channel. Passing it makes the close belong to that channel object rather than to the id it used: a channel displaced from the registry and torn down later closes nothing, because the id is already serving its replacement. Omitting it closes the scope unconditionally, which is what a lone owner and a manual host call both want.

create async

create(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)

Register a new pending input request and schedule the callback.

Returns as soon as the request is answerable — the _on_input_required callback runs in a background task and a denial from it rejects the request wherever wait() has got to.

wait() owns this request's cleanup; for a request no one will wait on, use :meth:create_detached.

actor_id names whose turn raised the request, so a notification layer can ask that person rather than the whole room. The native :class:HumanInputToolHandler fills it from the tool loop; a caller driving its own loop passes what it knows.

create_detached async

create_detached(tool_name, arguments, *, room_id='', tool_call_id='', channel_id='', channel_type=AI, actor_id=None)

Register a pending request that no one will :meth:wait on.

For runtimes that own their own tool loop — a Claude Code sandbox, say — where create() exists to raise the request and the answer travels back another way. Nothing here retires the request, so its creator MUST call :meth:release when done with it; otherwise the entry lives as long as the handler.

wait async

wait(pending_id, *, timeout=300)

Block until the request is resolved, rejected, or times out.

An outcome already reached and consumed is replayed from the retention, so waiting twice — or waiting after someone else dropped their own record of the request — reports what happened rather than an error.

Returns:

Type Description
str

The result string on resolution.

Raises:

Type Description
TimeoutError

If the timeout expires, or if a retained request had timed out.

RuntimeError

If the request was rejected.

ValueError

If pending_id is unknown — never seen, or retired long enough ago to have been evicted from the retention.

release

release(pending_id)

Drop a request whose cleanup the caller owns.

The counterpart of :meth:create_detached. A request still unanswered is rejected on the way out, so a stray waiter unblocks instead of hanging; the outcome goes to the retention either way and stays readable by :meth:wait.

Returns True if an active request was dropped.

resolve

resolve(pending_id, result)

Resolve a pending request with a result.

Returns True if the request was found and resolved.

reject

reject(pending_id, reason='')

Reject a pending request.

Returns True if the request was found and rejected.

HumanInputToolHandler

HumanInputToolHandler(tool_names, timeout=300, handler=None, tool_definitions=None)

ToolHandler wrapper that blocks on human input for specified tools.

Composes with other handlers via :func:~roomkit.tools.compose.compose_tool_handlers. Falls through (returns "Unknown tool" error) for non-matching tool names so the compose chain continues to the next handler.

Pass this to :class:~roomkit.channels.ai.AIChannel via the human_input_handler parameter — the channel auto-composes it and the framework injects the ON_USER_INPUT_REQUIRED hook callback at registration time.

handler property

handler

The underlying :class:HumanInputHandler for resolve/reject access.

tools property

tools

Tool definitions to inject into the AI context.

__call__ async

__call__(name, arguments)

ToolHandler protocol — blocks on matching tools, falls through otherwise.

PendingInput dataclass

PendingInput(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, status=PENDING, result=None, reject_reason=None, detached=False, created_at=_utcnow(), actor_id=None, _event=Event())

A pending human input request.

Mutable — transitions from PENDING to RESOLVED/REJECTED/TIMED_OUT when the application calls :meth:HumanInputHandler.resolve or :meth:HumanInputHandler.reject.

detached class-attribute instance-attribute

detached = False

No one will call wait() on this request — its creator frees it with :meth:HumanInputHandler.release. wait() owns the cleanup of every other request.

actor_id class-attribute instance-attribute

actor_id = None

Participant whose turn raised this request, when the tool loop knew one.

A request that names nobody is a request a notification layer has to broadcast, and an answer it cannot attribute. None when the turn had no author (a system injection, a webhook, a scheduled run) or when the creator runs its own tool loop and did not supply one. It names the turn without authenticating it — resolve it against the room's roster before treating it as a principal, as current_tool_actor_id() documents.

PendingInputEvent dataclass

PendingInputEvent(pending_id, tool_name, arguments, room_id, tool_call_id, channel_id, channel_type, timestamp=_utcnow(), actor_id=None)

Event fired through ON_USER_INPUT_REQUIRED hooks.

Carries the pending request details so notification layers (WebSocket, REST, etc.) can inform the user.

pending_id instance-attribute

pending_id

Handler-generated ID for resolving this request.

tool_name instance-attribute

tool_name

Name of the tool that requires human input.

arguments instance-attribute

arguments

Tool arguments (e.g. questions, options).

room_id instance-attribute

room_id

Room where the tool call originated.

tool_call_id instance-attribute

tool_call_id

Provider-assigned tool call ID.

channel_id instance-attribute

channel_id

Channel that triggered the tool call.

channel_type instance-attribute

channel_type

Type of the originating channel.

timestamp class-attribute instance-attribute

timestamp = field(default_factory=_utcnow)

When the pending request was created.

actor_id class-attribute instance-attribute

actor_id = None

Participant whose turn raised the request, when the tool loop knew one.

What lets a notification layer ask the person who asked rather than everyone in the room. Appended rather than grouped with the origin fields above so existing positional construction keeps working. None when the turn had no author.

PendingInputStatus

Bases: StrEnum

Status of a pending human input request.