Tools as an IPC Convention
Tools as an IPC Convention
Tools are not a kernel feature. The kernel has no concept of a tool name, a tool schema, or what it means to execute a tool. What the kernel does is route IPC events by topic and invoke registered interceptors. Tools are a convention layered on top of those primitives: a set of agreed topic names, a well-known payload shape, and a handful of capsules that honor the contract.
This page traces the full lifecycle of a tool call through the bus, grounding every claim in the code that implements it.
The Two Payload Types
Two variants of IpcPayload carry all tool traffic. Both live in
core/crates/astrid-types/src/ipc.rs:
pub enum IpcPayload {
// ...
ToolExecuteRequest {
call_id: String,
tool_name: String,
arguments: Value,
},
ToolExecuteResult {
call_id: String,
result: crate::llm::ToolCallResult,
},
// ...
}
call_id is a caller-generated correlation token that threads through the entire round trip.
tool_name is an opaque string to the kernel. The kernel serializes IpcPayload to JSON via
serde and hands the bytes to the WASM guest; neither the tag ("tool_execute_request") nor
the field values are inspected by kernel code.
ToolCallResult carries call_id: String, content: String, and is_error: bool. There is no structured error
type. The tool capsule produces a human-readable string and a boolean; the LLM provider receives
exactly that.
The Topic Namespace
The tool convention uses three topic families:
| Direction | Topic | Description |
|---|---|---|
| react to router | tool.v1.request.execute | Initiate execution |
| router to tool capsule | tool.v1.execute.<name> | Capsule-specific dispatch |
| tool capsule to router | tool.v1.execute.<name>.result | Per-capsule result |
| router to react | tool.v1.execute.result | Unified result back to react loop |
| react to tool capsules | tool.v1.request.cancel | Abort in-flight executions |
| prompt-builder to capsules | tool.v1.request.describe | Schema fan-out request |
| tool capsule to prompt-builder | tool.v1.response.describe.<source_id> | Schema response |
None of these topics are registered in the kernel. They are dot-separated strings that flow
through EventBus::publish as AstridEvent::Ipc events. The bus matches them against
interceptor patterns from Capsule.toml; the kernel’s role ends there.
Execution Flow: React to Tool Capsule
When the react loop decides the LLM wants to call a tool, it publishes a
ToolExecuteRequest on tool.v1.request.execute:
// capsules/astrid-capsule-react/src/lib.rs:1107
ipc::publish_json(
"tool.v1.request.execute",
&IpcPayload::ToolExecuteRequest {
call_id: tc.id.clone(),
tool_name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
)
The router capsule (capsules/astrid-capsule-router/) has declared an interceptor for this topic
in its Capsule.toml:
[subscribe]
"tool.v1.request.execute" = { wit = "@unicity-astrid/wit/types/tool-call", handler = "handle_execute_request" }
"tool.v1.execute.*.result" = { wit = "@unicity-astrid/wit/types/tool-call-result", handler = "handle_execute_result" }
When the dispatcher finds a matching handle_execute_request interceptor, it invokes the router
WASM guest with the serialized payload. The router unpacks the ToolExecuteRequest and computes
the forward topic:
// capsules/astrid-capsule-router/src/lib.rs:48
let forward_topic = format!("tool.v1.execute.{tool_name}");
Before doing so it validates the tool name. Dots are specifically forbidden to prevent topic
injection: a tool_name of "foo.bar" would produce tool.v1.execute.foo.bar, a four-segment
topic that could match a different capsule’s interceptor pattern. The validation rejects anything
that is not alphanumeric, hyphen, underscore, or colon:
// capsules/astrid-capsule-router/src/lib.rs:39-45
if tool_name.is_empty()
|| tool_name
.chars()
.any(|c| !c.is_alphanumeric() && c != '-' && c != '_' && c != ':')
{
log::warn(format!("Rejected invalid tool name: {tool_name}"));
return Self::publish_error_result(&call_id, format!("Invalid tool name: {tool_name}"));
}
The kernel does not enforce this constraint. The router capsule enforces it.
The router then publishes a fresh ToolExecuteRequest to tool.v1.execute.<name>. The tool
capsule that wants to handle foo declares:
[subscribe]
"tool.v1.execute.foo" = { wit = "...", handler = "execute_foo" }
The dispatcher fires the matching interceptor on that capsule. The tool capsule performs its work
and publishes a ToolExecuteResult on tool.v1.execute.foo.result (or any topic matching
tool.v1.execute.*.result).
The router’s second interceptor catches the result and republishes it on
tool.v1.execute.result, the single consolidated topic the react loop polls:
// capsules/astrid-capsule-router/src/lib.rs:85-88
ipc::publish_json(
"tool.v1.execute.result",
&IpcPayload::ToolExecuteResult { call_id, result },
)
The react loop receives this on its registered interceptor for tool.v1.execute.result and
records the result against the pending DispatchedToolCall keyed by call_id.
The Kernel Is Unaware of All of This
No kernel crate contains the string "tool.v1" in dispatching logic. astrid-capsule/src/dispatcher.rs
calls find_matching_interceptors which calls topic_matches from astrid-capsule/src/topic.rs.
topic_matches compares dot-separated segments; it knows nothing about tools. The entire routing
decision is: does the pattern in this capsule’s Capsule.toml match the topic on this event?
If yes, invoke the interceptor. If no, skip. That is all.
The Describe Protocol
Before tool execution can happen, the prompt-builder capsule must know which tools exist and what their JSON schemas look like. The kernel has no tool registry. Discovery uses the same bus:
- The prompt-builder subscribes to
tool.v1.response.describe.*before publishing. - It publishes an empty JSON object on
tool.v1.request.describe. - Each tool capsule that has an interceptor for
tool.v1.request.describewakes, assembles a{ "tools": [...] }envelope, and publishes it ontool.v1.response.describe.<its own source_id>. - The prompt-builder drains responses for a bounded window
(
TOOL_DESCRIBE_FANOUT_TIMEOUT_MS, currently 500 ms), deduplicates by tool name (first occurrence wins), and caches the result in KV under__tool_schema_cache.
// capsules/astrid-capsule-prompt-builder/src/lib.rs:501,513
let sub = ipc::subscribe("tool.v1.response.describe.*")?;
ipc::publish("tool.v1.request.describe", "{}")?;
The fan-out window is bounded and approximate. A capsule that responds after the window closes is silently ignored. There is no ordering guarantee across capsule responses. The deduplication rule (first wins) means two capsules that export a tool with the same name will have one schema silently dropped.
The collected schemas are serde_json::Value objects. The prompt-builder treats them as opaque
JSON for inclusion in the LlmRequest payload. The LlmToolDefinition type in
core/crates/astrid-types/src/llm.rs captures name, description, and input_schema:
pub struct LlmToolDefinition {
pub name: String,
pub description: Option<String>,
pub input_schema: Value,
}
The kernel does not validate schemas. No WIT type-checking happens at runtime on tool schemas.
The Mutable Flag
Capsule.toml tool entries carry a mutable boolean:
// core/crates/astrid-capsule/src/manifest/mod.rs:636-647
pub struct ToolDef {
pub name: String,
pub description_for_llm: String,
pub input_schema_wit: Option<String>,
pub mutable: bool,
}
The SDK #[astrid::tool] attribute reflects this. Tools annotated mutable in capsule-fs
include write_file, replace_in_file, create_directory, delete_file, and move_file:
// capsules/astrid-capsule-fs/src/lib.rs:108,119
#[astrid::tool("write_file", mutable)]
pub fn write_file(&self, args: WriteFileArgs) -> Result<String, SysError> { ... }
#[astrid::tool("replace_in_file", mutable)]
pub fn replace_in_file(&self, args: ReplaceInFileArgs) -> Result<String, SysError> { ... }
Read-only tools like list_directory and grep_search omit the flag. The field is carried in the
tool schema response so the prompt-builder and approval layer can key on it, but the flag has no
effect on routing. The kernel does not gate on mutability. Whether an approval prompt fires for a
mutable tool is a capsule-space decision, not a kernel enforcement.
Dispatcher Topic Matching
The interceptor matching used for tool topics follows topic_matches in
core/crates/astrid-capsule/src/topic.rs. The rule is strict: segment count must be equal, and
* matches exactly one segment.
// core/crates/astrid-capsule/src/topic.rs
pub(crate) fn topic_matches(topic: &str, pattern: &str) -> bool {
if !has_valid_segments(topic) || !has_valid_segments(pattern) {
return false;
}
if topic.split('.').count() != pattern.split('.').count() {
return false;
}
topic
.split('.')
.zip(pattern.split('.'))
.all(|(t, p)| p == "*" || t == p)
}
This differs from the EventReceiver topic filter on the bus, which supports trailing .* to
match one or more segments. The interceptor matcher requires equal depth. A pattern like
tool.v1.execute.*.result matches tool.v1.execute.write_file.result (four-segment wildcard at
position four) but does not match tool.v1.execute.result (only three segments after the prefix).
Topic injection via a crafted tool_name containing a dot would produce a six-segment result topic that the five-segment router pattern tool.v1.execute.*.result cannot match, because the segment counts differ,
which is the defense the router’s name
validation backstops.
Bus-Level Delivery Mechanics
Each AstridEvent::Ipc carries the topic in IpcMessage::topic. When EventBus::publish is
called, it does three things:
- Broadcasts to the tokio
broadcast::Sender<Arc<AstridEvent>>for all unfiltered subscribers. - Notifies the synchronous
SubscriberRegistry. - Fans out to the per-(capsule, topic, principal)
routestable viadispatch_to_routes.
The routes table is used by the per-principal IPC routing demux introduced to fix the concurrency
cliff at N > buffer_capacity principals. Each route entry holds its own bounded byte budget and
per-principal FIFO queues; the RoutedEventReceiver drains them with deficit-round-robin fairness.
For tool traffic specifically, the per-principal keying means a tool result for principal alice
will not head-of-line block a tool result for principal bob even if both are waiting on
tool.v1.execute.result.
The dispatcher side uses per-(capsule, principal) mpsc queues of depth 64. If a capsule’s queue
is full, the event is dropped with a warning. There is no back-pressure to the publisher.
The Router Is Stateless Middleware
ToolRouter in capsules/astrid-capsule-router/src/lib.rs carries no state: it derives
Default and holds no fields. Every call to handle_execute_request or handle_execute_result
is a pure transform of the incoming payload: validate, compute a topic, re-publish. If the
re-publish fails, the router publishes an error result on tool.v1.execute.result so the react
loop does not hang waiting for a result that will never arrive:
// capsules/astrid-capsule-router/src/lib.rs:94-106
fn publish_error_result(call_id: &str, error_message: String) -> Result<(), SysError> {
ipc::publish_json(
"tool.v1.execute.result",
&IpcPayload::ToolExecuteResult {
call_id: call_id.to_string(),
result: ToolCallResult {
call_id: call_id.to_string(),
content: error_message,
is_error: true,
},
},
)
}
The router publishes on tool.v1.execute.* (wildcard) and tool.v1.execute.result, declared in
[publish]. The kernel’s IPC publish ACL enforces that the router can only publish to these
topics. A router capsule cannot publish to agent.v1.response or any other topic outside its
declared publish surface.
Cancellation
Cancellation is a separate signal rather than a cancellation of an in-flight interceptor call.
When the react loop receives a cancel request, it publishes ToolCancelRequest on
tool.v1.request.cancel:
// capsules/astrid-capsule-react/src/lib.rs:1198-1200
ipc::publish_json(
"tool.v1.request.cancel",
&IpcPayload::ToolCancelRequest { call_ids },
)
The host-level process tracker in the kernel listens on tool.v1.request.cancel and delivers
SIGINT (then SIGKILL) to any spawned child processes whose call_id matches. Tool capsules that
do not spawn host processes ignore the signal. There is no mechanism to interrupt a WASM guest
mid-execution once the interceptor call has started.
Implementing a New Tool Capsule
A capsule that wants to handle the tool my_tool:
- Declare a
[subscribe]entry bindingtool.v1.execute.my_toolto a handler function. - Declare a
[subscribe]entry fortool.v1.request.describewith a handler that publishes the JSON schema ontool.v1.response.describe.<source_id>. - Declare
[publish]entries fortool.v1.execute.my_tool.resultandtool.v1.response.describe.<source_id>. - Mark tools that mutate state with
mutable = truein[[tool]]entries so the approval layer can present the appropriate copy.
[subscribe]
"tool.v1.execute.my_tool" = { wit = "...", handler = "execute_my_tool" }
"tool.v1.request.describe" = { wit = "opaque", handler = "handle_describe" }
[publish]
"tool.v1.execute.my_tool.result" = { wit = "..." }
"tool.v1.response.describe.*" = { wit = "opaque" }
The kernel knows nothing about my_tool. It will deliver events to the capsule when the topic
matches, and the capsule will publish results when it is done. The only shared contract is the
IpcPayload enum and the topic naming convention described above.