agentkit-tools-core is the tool execution contract crate.
It should define:
- how tools are described to models
- how the loop invokes tools
- how permissions and approval requirements are expressed
- how tool results and execution failures are surfaced
- how native tools and MCP-backed tools fit the same registry
It should make custom tools easy to add while giving built-in tools a shared safety model.
agentkit-tools-core should not own:
- shell command execution details
- filesystem implementations
- MCP transport or process lifecycle
- loop control flow
- provider-specific tool calling formats
- prompt/context loading
It defines the execution boundary, not the individual tool products.
This should also remain the primary user-facing extension mechanism for v1.
If users want to add custom behavior, the first answer should be "implement a tool", not "implement a capability provider".
Recommended direction:
agentkit-coredefines normalized tool call and tool result value typesagentkit-capabilitiesdefines the lower-level invocable/resource/prompt layeragentkit-tools-coredefines tool traits, registry, execution context, and permission contracts on top of invocablesagentkit-loopdepends onagentkit-tools-coreto execute tool calls- built-in tool crates depend on
agentkit-tools-core agentkit-mcpadapts MCP tools/resources/prompts through these abstractions where appropriate
That gives one tool system instead of a separate path for every integration style.
Every tool has at least two different representations:
- a model-facing description
- an executable implementation
Those should be separate, even if one type happens to implement both.
The model-facing part is mostly stable:
- name
- description
- input schema
The execution part is operational:
- parse input
- check policy
- run action
- produce output
Separating them keeps the registry flexible and makes testing easier.
Every serious agent tool system eventually needs:
- allow/deny checks
- approval requirements
- scoped execution context
- clear error reasons
This should not be reinvented independently by shell, filesystem, and MCP tools.
It should also not make custom tools feel second-class.
Tool output should not be reduced immediately to a plain string.
The loop and provider adapters will often need richer forms:
- text
- structured JSON
- file references
- mixed content parts
agentkit-core already defines the value layer for tool calls/results. agentkit-tools-core should preserve that richness at execution time.
The safety model cannot come at the cost of making custom tools painful to implement.
The target should be:
- one trait for basic tools
- optional extension traits or hooks for advanced policy-aware tools
A host should be able to register:
- a native Rust tool
- a shell tool
- a filesystem tool
- an MCP tool proxy
through the same registry and with the same loop-facing execution path.
The lower-level agentkit-capabilities layer should sit underneath tools.
That means:
- tools are invocables with model-facing schema and permission semantics
- MCP tools can share the same invocable base
- MCP resources and prompts do not need to be distorted into tool traits
Custom tools should not be forced to squeeze their safety model into a catch-all enum variant.
They should be able to expose first-class permission request types through the shared permission-request trait.
The model-facing description should be explicit and serializable.
Recommended shape:
pub struct ToolSpec {
pub name: ToolName,
pub description: String,
pub input_schema: serde_json::Value,
pub output_schema: Option<serde_json::Value>,
pub annotations: ToolAnnotations,
pub metadata: MetadataMap,
}output_schema declares the JSON shape the tool returns. Provider tool-call APIs do not carry an output schema in their declarations, so it is not surfaced verbatim to the model. Hosts and composing tools may render it into the description or use it for validation; agentkit-tool-compose surfaces it through its Lua tools() helper so generated scripts can target the right return shape. Set it with ToolSpec::with_output_schema(schema).
Recommended ToolAnnotations fields:
read_only_hintdestructive_hintidempotent_hintneeds_approval_hintsupports_streaming_hint
These are hints, not guarantees. The actual enforcement comes from policy.
Why keep them:
- useful for model guidance
- useful for UI presentation
- useful for host-side policy defaults
Tool names should be first-class and validated.
Recommended newtype:
pub struct ToolName(String);Reasons:
- avoid accidental whitespace/case inconsistencies
- centralize validation rules
- allow future namespacing conventions
Suggested convention:
- lowercase ASCII
-,_, and.allowed- namespaced patterns such as
fs_read_fileormcp.github.search
The base tool trait should be small.
Conceptually, Tool should build on the lower-level Invocable abstraction from agentkit-capabilities.
Recommended shape:
pub trait Tool: Send + Sync {
fn spec(&self) -> &ToolSpec;
async fn invoke(
&self,
request: ToolRequest,
ctx: &mut ToolContext,
) -> Result<ToolResult, ToolError>;
// Optional. Default wraps `invoke`'s Result into
// ToolExecutionOutcome::Completed / ::Failed. Override only when a
// composing tool needs to propagate a nested `Interrupted` outcome
// (e.g. a child approval) up to the loop.
async fn invoke_outcome(
&self,
request: ToolRequest,
ctx: &mut ToolContext,
) -> ToolExecutionOutcome { /* ... */ }
}Where:
ToolRequestwraps the normalizedToolCallPartinput plus execution metadataToolContextgives access to scoped runtime capabilitiesToolResultwraps normalizedToolResultPartoutput plus execution metadata
This is enough for most tools.
Recommended ToolRequest contents:
call_idtool_name- raw JSON input
- session ID
- turn ID
- invocation metadata
Optional metadata may include:
- originating model/provider
- host-supplied request tags
- trace IDs
This lets tools make context-aware decisions without depending on loop internals.
ToolContext is the operational context the loop gives to tools.
It should contain only what tools actually need.
Recommended responsibilities:
- expose permission and approval facilities
- expose scoped environment information
- expose cancellation state if supported
- expose host-provided shared resources
Possible shape:
pub struct ToolContext<'a> {
pub session_id: &'a SessionId,
pub turn_id: &'a TurnId,
pub permissions: &'a dyn PermissionChecker,
pub resources: &'a dyn ToolResources,
pub cancellation: Option<TurnCancellation>,
pub execution_scope: Option<ToolExecutionScope>,
pub approved_request: Option<ApprovalRequest>,
}The important boundary is that tools do not reach directly into the loop. They get a narrow execution context.
execution_scope is an owned scope (executor + session + turn + permissions + resources + cancellation) that lets a composing tool invoke other tools through the same execution path. Call scope.execute_child(request) for a normal nested call and scope.execute_approved_child(request, approval) to resume a child past a recorded approval. Both return ToolExecutionOutcome, so a nested approval interruption can propagate up through the parent's invoke_outcome instead of being collapsed into an error.
approved_request is the ApprovalRequest currently being resumed when the invocation is the result of host approval. The BasicToolExecutor sets it on entry to execute_approved and restores the previous value on return.
The registry should be straightforward and deterministic.
Recommended shape:
pub struct ToolRegistry {
tools: BTreeMap<ToolName, Arc<dyn Tool>>,
}Recommended operations:
- register one tool
- register many tools
- get tool by name
- iterate specs
- freeze registry for execution
The loop needs two things from the registry:
- a stable list of tool specs to expose to the model
- a name lookup for invocation
The loop should not know every detail of policy evaluation and execution plumbing.
It should call into a higher-level execution boundary such as:
pub trait ToolExecutor {
async fn execute(
&self,
request: ToolRequest,
ctx: &mut ToolContext,
) -> ToolExecutionOutcome;
}Why this extra layer is useful:
- registry lookup
- permission checks
- approval determination
- metrics and timing
- tool invocation
- error normalization
can all live behind one call.
This gives you a place to centralize safety logic instead of duplicating it in every tool crate.
Tool execution needs more than success/failure.
Recommended shape:
pub enum ToolExecutionOutcome {
Completed(ToolResult),
Interrupted(ToolInterruption),
Failed(ToolError),
}Where ToolInterruption captures blocking cases such as:
- approval required
- auth required
- missing host capability
This matters because not every tool execution failure is an actual error.
In many cases, the correct result is:
- emit an approval-required event
- return a loop interrupt
- resume the exact same tool execution later
Permissions should have a shared contract.
Recommended base trait:
pub trait PermissionChecker: Send + Sync {
fn evaluate(&self, action: &ProposedToolAction) -> PermissionDecision;
}Recommended decision type:
pub enum PermissionDecision {
Allow,
Deny { reason: String },
RequireApproval(ApprovalRequest),
}This is the main unifying concept for safety.
Built-in tools should describe their risky behavior as normalized ProposedToolAction values that the permission checker can evaluate.
Examples:
- run shell command
- read file
- write file
- delete file
- connect to MCP server
- invoke remote MCP tool with auth scope
Tools need a way to explain what they are about to do before they do it.
The permission layer should expose a trait-based request model.
That means tools should be able to preflight one or more PermissionRequest values before execution.
Built-in tools should use built-in request families. Custom tools may define their own request types.
Tool execution may need to stop for reasons beyond generic approval.
Two important cases:
- explicit host approval
- external auth completion
Recommended approach:
- represent both as
ToolInterruption - let the loop translate those into blocking
LoopInterrupts - let the host resume execution after satisfying the requirement
Possible shape:
pub enum ToolInterruption {
ApprovalRequired(ApprovalRequest),
AuthRequired(AuthRequest),
}This is important for MCP, where auth is a first-class concern.
The execution layer should preserve both semantic output and operational metadata.
Recommended shape:
pub struct ToolResult {
pub result: ToolResultPart,
pub duration: Option<Duration>,
pub metadata: MetadataMap,
}This allows:
- normalized transcript integration via
ToolResultPart - operational reporting via metadata and duration
Tool errors should be categorized clearly.
Recommended categories:
- invalid input
- permission denied
- execution failed
- unavailable
- interrupted
- internal tool error
The error type should be expressive enough for reporting and policy, but not so detailed that every tool invents a unique taxonomy.
Custom tools should not be forced to implement the entire safety stack themselves.
Recommended ergonomics:
- implement
Tool - optionally expose a preflight
ProposedToolAction - let the executor perform default permission handling
A simple custom tool should be possible with:
pub struct EchoTool;
impl Tool for EchoTool {
fn spec(&self) -> &ToolSpec { /* ... */ }
async fn invoke(
&self,
request: ToolRequest,
_ctx: &mut ToolContext,
) -> Result<ToolResult, ToolError> {
/* ... */
}
}Advanced tools can opt into richer action descriptions for better policy support.
To support shared permission logic cleanly, tools should optionally expose what they plan to do before they execute it.
Recommended extension trait:
pub trait PreflightTool: Tool {
fn proposed_requests(
&self,
request: &ToolRequest,
) -> Result<Vec<Box<dyn PermissionRequest>>, ToolError>;
}This lets the executor:
- inspect intended requests
- run permission checks
- raise approval interrupts
- avoid partially executing side effects before approval
This is especially important for shell, filesystem, and MCP tools.
V1 built-ins should be separate crates using the same contracts.
Mandatory built-in focus for the first public release:
- filesystem tools
- a simple and low-friction path for implementing custom tools
Other built-ins can follow once the base extension path feels good in code.
Responsibilities:
- command execution
- cwd scoping
- environment filtering
- timeout handling
- optional PTY support if added later
Should emit structured ShellPermissionRequest preflight values such as:
- command line
- cwd
- env policy summary
Should not own the global approval model.
Responsibilities:
- read file
- write file
- edit file
- list directory
- create/delete/move operations as scoped by policy
Should emit structured FileSystemPermissionRequest values such as:
- read path
- write path
- delete path
- move path
Should rely on shared permission decisions rather than inline one-off checks where possible.
MCP is broader than ordinary local tools, but the tool-facing path should still align.
Responsibilities:
- discover MCP tools
- surface MCP tool specs
- invoke MCP tools through the same execution path
- surface auth and capability interruptions
- expose MCP resources and prompts through adjacent abstractions
Not everything in MCP is a tool, so the crate will likely have additional abstractions for:
- resources
- prompts
- server/session management
But MCP tools themselves should still fit naturally into the same registry/executor boundary.
V1 should keep schema generation simple.
Recommended approach:
- tools provide JSON Schema explicitly
- helper macros or derive support can come later
Reason:
- avoids locking into one schema generation library too early
- keeps the base contract explicit
- makes MCP and custom remote tools easier to adapt
If ergonomic schema helpers are needed later, add them as an optional companion crate.
V1 should not require streaming tool outputs.
However, the design should leave room for it later through:
supports_streaming_hint- future
ToolEventor streamed result extensions
For now:
- tools are request/response
- the loop handles tool execution as an atomic step from the model's perspective
That is enough for the first version.
Recommended first-pass types:
pub struct ToolSpec { /* ... */ }
pub struct ToolRequest { /* ... */ }
pub struct ToolResult { /* ... */ }
pub struct ToolRegistry { /* ... */ }
pub struct ToolContext<'a> { /* ... */ }
pub trait Tool { /* ... */ }
pub trait PreflightTool: Tool { /* ... */ }
pub trait ToolExecutor { /* ... */ }
pub trait PermissionChecker { /* ... */ }
pub enum ToolExecutionOutcome { /* ... */ }
pub enum ProposedToolAction { /* ... */ }
pub enum PermissionDecision { /* ... */ }
pub enum ToolInterruption { /* ... */ }This is enough to support:
- custom tools
- built-in tools
- shared permission checks
- approval interrupts
- MCP-backed tools
At the crate level:
stdexecutorpermissions
The base trait/types should stay as small as possible.
If executor plumbing becomes too opinionated, it can be split into a sibling crate later. But for v1, keeping registry plus execution policy together is likely simpler.
These concerns should stay elsewhere:
- transcript item/content types:
agentkit-core - loop interrupts and turn lifecycle:
agentkit-loop - terminal/logging output:
agentkit-reporting - actual shell/process code:
agentkit-tool-shell - actual filesystem code:
agentkit-tool-fs - context loading:
agentkit-context
The tool crate should be the narrow middle layer between orchestration and specific tool implementations.
agentkit-tools-core/
src/
lib.rs
spec.rs
tool.rs
request.rs
result.rs
registry.rs
executor.rs
context.rs
permissions.rs
action.rs
interrupt.rs
error.rs
Module intent:
spec.rs:ToolSpec,ToolName, annotationstool.rs:Tool,PreflightToolrequest.rs:ToolRequestresult.rs: execution result wrapperregistry.rs: registration and lookupexecutor.rs: execution orchestrationcontext.rs:ToolContextand host resourcespermissions.rs: permission traits and decisionsaction.rs:ProposedToolActionand built-in action typesinterrupt.rs:ToolInterruption, auth/approval requestserror.rs: tool-layer errors
Before locking the tools API, prove:
- a minimal custom tool can be implemented with very little boilerplate
- shell and filesystem tools can describe preflight actions before side effects
- permission checks can produce allow, deny, and approval-required outcomes cleanly
- MCP-backed tools can be surfaced through the same registry and executor path
- tool results can carry structured output without collapsing to plain text
- the loop can pause and resume around a tool interruption without special-casing one tool type
If any of those are awkward, the boundary is probably still wrong.