Remote Tool Call Mechanism¶
Overview¶
Remote Tool Call is one of ClawSeed's core features, allowing mobile clients to register and execute tools over WebSocket. The agent has no distinction between local and remote tools — both implement the Tool trait and are invoked identically.
Note: Remote tools are registered in two separate registries — the gateway-wide
AppState.tool_registry(for/api/toolsvisibility) and the per-connectionAgent.tool_registry(for actual execution). See "Connection Lifecycle" below for the full three-step registration flow.
Architecture Overview¶
┌──────────────────┐ ┌──────────────────┐
│ Mobile Client │ │ Gateway Server │
│ │ │ │
│ ClawseedClient │ 1. register_tools │ WebSocket │
│ (OkHttp WS) │ ──────────────────────→ │ Handler │
│ │ │ ↓ │
│ │ 2. tools_registered │ RemoteTool │
│ │ ←────────────────────── │ Registry │
│ │ │ ↓ │
│ │ │ Agent │
│ │ │ .tool_registry │
│ │ │ (Arc<dyn │
│ │ │ ToolRegistry>) │
│ │ │ │
│ Tool Executor │ 3. tool_call_request │ Agent Loop │
│ (ToolCall │ ←────────────────────── │ calls RemoteTool│
│ Handler) │ │ .execute() │
│ │ │ │
│ │ 4. tool_result │ Waits for │
│ │ ──────────────────────→ │ response │
│ │ │ (30s timeout) │
│ │ │ ↓ │
│ │ 5. result_acknowledged │ Returns result │
│ │ ←────────────────────── │ to Agent Loop │
└──────────────────┘ └──────────────────┘
Server-Side Implementation¶
RemoteTool — Remote Tool Wrapper¶
RemoteTool implements the Tool trait, bridging execution to the WebSocket client:
pub struct RemoteTool {
spec: ToolSpec,
request_tx: mpsc::Sender<RemoteToolRequest>,
}
#[async_trait]
impl Tool for RemoteTool {
fn name(&self) -> &str { &self.spec.name }
fn description(&self) -> &str { &self.spec.description }
fn parameters_schema(&self) -> Value { self.spec.parameters.clone() }
async fn execute(&self, args: Value, _ctx: &dyn ToolContext) -> Result<ToolResult> {
let (response_tx, response_rx) = oneshot::channel();
let call_id = Uuid::new_v4().to_string();
// Send request to WebSocket handler
self.request_tx.send(RemoteToolRequest {
call_id: call_id.clone(),
tool_name: self.spec.name.clone(),
args,
response_tx,
}).await?;
// Wait for client response (30-second timeout)
match tokio::time::timeout(
Duration::from_secs(30),
response_rx,
).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Err(anyhow!("Channel closed")),
Err(_) => Err(anyhow!("Remote tool timeout (30s)")),
}
}
}
Key design:
- Uses mpsc::Sender to send requests to the WebSocket handler
- Uses oneshot::channel to await a single response
- 30-second timeout prevents indefinite waiting
- Does not use ToolContext (no access to server-side capabilities)
RemoteToolRegistryHandle — Tool Registration Manager¶
pub struct RemoteToolRegistryHandle {
tools: Vec<RemoteTool>,
request_rx: mpsc::Receiver<RemoteToolRequest>,
}
Manages tools registered from WebSocket clients, providing a request receiver channel.
WebSocket Handler¶
The WebSocket handler processes tool registration and request forwarding:
async fn handle_ws(socket: WebSocket, agent: Agent) {
let (registry_handle, request_rx) = RemoteToolRegistryHandle::new();
let session_id = generate_session_id();
while let Some(msg) = socket.next().await {
match msg {
// Tool registration
Ok(Text(text)) if type == "register_tools" => {
let remote_tools = registry_handle.build_tools();
agent.add_remote_tools(remote_tools, session_id.clone());
socket.send(tools_registered(count)).await;
}
// Tool result response
Ok(Text(text)) if type == "tool_result" => {
let result = ToolResult { success: true, output, error: None };
response_tx.send(result);
}
// Tool error response
Ok(Text(text)) if type == "tool_error" => {
let result = ToolResult { success: false, output: String::new(), error: Some(err) };
response_tx.send(result);
}
}
}
// On WebSocket disconnect, bulk-remove via ToolSource::Remote { session }
// tool_registry.unregister_by_source() automatically cleans up all remote tools for this session
}
Client-Side Implementation¶
Tool Registration¶
// Build tool specification
val toolSpec = ToolSpec(
name = "device_info",
description = "Get Android device information including model, manufacturer, Android version",
parameters = """{"type":"object","properties":{},"required":[]}"""
)
// Register via Builder
val client = ClawseedClient.builder(url)
.registerTool(toolSpec)
.toolCallHandler { request ->
when (request.name) {
"device_info" -> ToolCallResult.Success(queryDeviceInfo())
else -> ToolCallResult.Failure("unknown tool")
}
}
.build()
Tool Call Handling¶
// When a tool_call_request message is received
private fun dispatchToolCall(request: ToolCallRequest) {
val handler = toolCallHandler ?: run {
// No handler registered, return error
webSocket?.send(ToolCallResult.Failure("No handler").toJson(request.id).toString())
return
}
// Execute on single-threaded executor to avoid races
executor.execute {
val result = runCatching { handler.handleToolCall(request) }
.getOrElse { ToolCallResult.Failure(it.message ?: "Exception") }
// Send result back immediately via WebSocket
webSocket?.send(result.toJson(request.id).toString())
}
}
Message Protocol Details¶
Tool Registration Phase¶
// Client → Server
{
"type": "register_tools",
"tools": [
{
"name": "device_info",
"description": "Get device information",
"parameters": {"type": "object", "properties": {}, "required": []}
},
{
"name": "camera",
"description": "Take a photo",
"parameters": {
"type": "object",
"properties": {
"quality": {"type": "string", "enum": ["low", "medium", "high"]}
}
}
}
]
}
// Server → Client
{
"type": "tools_registered",
"count": 2,
"registered": 2
}
Tool Invocation Phase¶
// Server → Client (request tool execution)
{
"type": "tool_call_request",
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "device_info",
"args": {}
}
// Client → Server (success result)
{
"type": "tool_result",
"id": "550e8400-e29b-41d4-a716-446655440000",
"output": "{\"model\":\"Pixel 8\",\"manufacturer\":\"Google\",\"android_version\":\"14\"}",
"success": true
}
// Client → Server (error result)
{
"type": "tool_error",
"id": "550e8400-e29b-41d4-a716-446655440000",
"error": "Camera permission denied",
"success": false
}
// Server → Client (acknowledge result received)
{
"type": "result_acknowledged",
"id": "550e8400-e29b-41d4-a716-446655440000"
}
Remote vs. Local Tools¶
| Feature | Local Tool | Remote Tool |
|---|---|---|
| Registration | all_tools() (CLI) or shared_builtin_tools (gateway), registered as ToolSource::BuiltIn |
WebSocket register_tools message, registered as ToolSource::Remote { session } |
| Execution location | Gateway server | Client device |
| ToolContext | Full access (Memory, SecurityPolicy, etc.) | Not used |
| Timeout | Unlimited | 30 seconds |
| Lifecycle | With Gateway process | With WebSocket connection |
| Typical use | File ops, shell, web requests | Device capabilities (camera, sensors, contacts) |
| Error handling | ToolResult::error |
tool_error message or timeout |
Connection Lifecycle¶
WebSocket connection established
↓
Client sends register_tools
↓
Gateway creates RemoteTool instances:
1. Register to shared AppState.tool_registry via register_or_replace() (ToolSource::Remote { session })
→ Makes tools visible via /api/tools endpoint
2. Inject into per-connection Agent via agent.add_remote_tools(tools, session)
→ Makes tools callable by the agent
↓
Normal conversation and tool calls
↓
WebSocket disconnects
↓
Gateway removes remote tools from shared registry via tool_registry.unregister_by_source()
(Agent-scoped tools are cleaned up when the Agent is dropped)
↓
Subsequent conversations no longer call the disconnected client's tools
Important: Remote tool lifecycle is bound to the WebSocket connection. On disconnect, associated tools are automatically removed from both the shared registry and the agent.
Dual Registry Implication: The shared
AppState.tool_registryand eachAgent.tool_registryare independent./api/toolsmay show tools from other connections that the current agent cannot invoke. In single-connection scenarios (current Android demo), the two registries are effectively in sync.
Typical Use Cases¶
Device Information¶
ToolSpec("device_info", "Get device information",
"""{"type":"object","properties":{},"required":[]}""")
Camera Operations¶
ToolSpec("camera", "Take a photo",
"""{"type":"object","properties":{"quality":{"type":"string","enum":["low","high"]}},"required":[]}""")
Contact Queries¶
ToolSpec("contacts", "Query phone contacts",
"""{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}""")
Sensor Data¶
ToolSpec("sensors", "Read sensor data",
"""{"type":"object","properties":{"type":{"type":"string","enum":["accelerometer","gyroscope","gps"]}},"required":["type"]}""")
Error Handling¶
| Scenario | Handling |
|---|---|
| No tool handler registered | Return tool_error with message "No handler registered" |
| Tool execution throws exception | Catch exception, return tool_error with exception message |
| Client doesn't respond within 30s | Gateway returns timeout error to Agent |
| WebSocket disconnects | Remove all remote tools; agent won't call them |
| Unmatched call_id | Discard uncorrelatable responses |
Security Considerations¶
- Remote tools cannot access server-side capabilities (Memory, SecurityPolicy, Provider)
- Tool parameters are validated by the client
- Gateway still intercepts tool calls through the Hook pipeline
before_tool_callhooks can cancel remote tool calls- Recommendation: Restrict registerable tool name ranges in
SecurityPolicy