Session 2: Tools + Agent Workflows
Duration: 50 minutes | Level: Intermediate .NET
Overview#
In agent architecture, a tool is any capability the AI can invoke to interact with the world — reading files, running commands, fetching web pages, scheduling tasks. The difference between a chatbot and an agent is simple: a chatbot generates text; an agent uses tools.
This session follows the "Explain → Explore → Extend" approach: we explain the architecture, explore the pre-built code, then extend it with small Copilot-assisted changes. Security is a first-class concern — every tool has defense-in-depth protections against real-world attack vectors.
What Attendees Will Understand#
- How the tool abstraction layer enables extensibility
- Why security gates (approval, validation, blocklists) are essential
- How the agent loop coordinates model reasoning with tool execution
- The separation between
IToolRegistry(what tools exist) andIToolExecutor(how tools run safely)
Stage 1: Tool Architecture (12 min)#
Concepts to Explain#
- What makes an agent vs a chatbot: Tool use! A chatbot generates text. An agent decides it needs to do something — read a file, run a command, fetch a URL — and requests a tool call. The model doesn't execute anything; it emits a structured tool call that our code executes.
- ITool interface: Every tool implements
IToolwith four members:Name— unique identifier (e.g.,"file_system","shell")Description— what the tool does (fed to the LLM)Metadata— parameter schema, approval requirements, category, tagsExecuteAsync(ToolInput, CancellationToken)— the actual execution
- Approval policies:
IToolApprovalPolicywith two methods:RequiresApprovalAsync— does this tool need human approval?IsApprovedAsync— has it been approved?- Built-in:
AlwaysApprovePolicy(auto-approve everything) - ShellTool sets
RequiresApproval = truein its metadata
- IToolRegistry and IToolExecutor separation of concerns:
IToolRegistrymanages tool discovery:Register,GetTool,GetAllTools,GetToolManifestIToolExecutormanages safe execution: lookup → approval check → execute → log- Why separate? Registry is about what exists; executor is about how to run safely
Code Walkthrough#
Tools.Abstractions (7 files, 90 LOC)#
Walk through each file briefly:
-
ITool.cs— The core interface. Every tool in the system implements this. Point out thatExecuteAsyncreturnsToolResult, not raw strings. -
IToolExecutor.cs— Two methods:ExecuteAsync(single tool) andExecuteBatchAsync(multiple tools). The executor doesn't know about specific tools — it uses the registry. -
IToolRegistry.cs— Four methods:Register,GetTool,GetAllTools,GetToolManifest. The manifest returns metadata only (no execution capability) — safe to expose to the model. -
IToolApprovalPolicy.cs— The security gate interface. IncludesAlwaysApprovePolicyas a default. In production, you'd implement a policy that checks user permissions. -
ToolInput.cs— Wraps raw JSON arguments with helper methods:GetArgument<T>,GetStringArgument. UsesJsonDocument.Parsefor zero-allocation access. -
ToolMetadata.cs— What the LLM sees: Name, Description,ParameterSchema(JSON Schema),RequiresApproval, Category, Tags. -
ToolResult.cs— Success/failure with output, error, and duration. Factory methods:ToolResult.Ok(...)andToolResult.Fail(...).
Tools.Core (3 files, 101 LOC)#
-
ToolExecutor.cs— The approval gate pattern:// 1. Lookup var tool = _registry.GetTool(toolName); if (tool is null) return ToolResult.Fail(...); // 2. Approval check if (await _approvalPolicy.RequiresApprovalAsync(toolName, arguments) && !await _approvalPolicy.IsApprovedAsync(toolName, arguments)) return ToolResult.Fail(...); // 3. Execute with timing var sw = Stopwatch.StartNew(); var result = await tool.ExecuteAsync(input, cancellationToken);Point out: every execution is logged with duration. The executor is a chokepoint — all tool calls flow through it.
-
ToolRegistry.cs— Thread-safe dictionary withStringComparer.OrdinalIgnoreCase. Simple but effective — tool names are case-insensitive. -
ToolsServiceCollectionExtensions.cs— DI wiring:AddToolFramework()registers Registry (singleton), Executor (scoped), ApprovalPolicy (singleton)AddTool<T>()registers individual tools as singletons
Live Demo#
Show the tool list endpoint: GET /api/tools
- Open browser or HTTP client
- Navigate to
https://localhost:{port}/api/tools - Show the JSON response — list of tool metadata (name, description, parameter schema)
- Point out: this is what the model sees when deciding which tool to call
Stage 2: Built-in Tools + Security (15 min)#
Concepts to Explain#
Three real-world security threats that agent tools must defend against:
- Path Traversal — An attacker (or confused LLM) tries
../../etc/passwdor..\..\Windows\System32. The file system tool must confine access to the workspace. - Command Injection — The LLM generates
rm -rf /orformat C:. The shell tool must block dangerous commands before they execute. - SSRF (Server-Side Request Forgery) — The LLM fetches
http://127.0.0.1:8080/adminorhttp://169.254.169.254/metadata. The web tool must block requests to internal/private networks.
Defense pattern: Each tool validates inputs before execution. Fail fast, fail safe.
Code Walkthrough#
FileSystemTool (OpenClawNet.Tools.FileSystem, 142 LOC)#
Key security features to highlight:
-
Blocked paths array:
private static readonly string[] BlockedPaths = [".env", ".git", "appsettings.Production"]; -
Path resolution with traversal prevention — the
ResolvePathmethod:var fullPath = Path.GetFullPath(Path.Combine(_workspaceRoot, relativePath)); if (!fullPath.StartsWith(_workspaceRoot, StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("Path traversal attempt blocked: {Path}", relativePath); return null; }Explain:
Path.GetFullPathresolves..segments. Then we check the result stays inside the workspace. The..is gone by the time we check — this catches all traversal tricks. -
File size limit: 1MB maximum to prevent memory exhaustion.
-
Three operations: read, write, list — each with appropriate guards.
ShellTool (OpenClawNet.Tools.Shell, 148 LOC)#
Key security features:
-
Command blocklist:
private static readonly HashSet<string> BlockedCommands = new(StringComparer.OrdinalIgnoreCase) { "rm", "del", "format", "fdisk", "mkfs", "dd", "shutdown", "reboot", "kill", "taskkill", "net", "reg", "regedit", "powershell", "cmd" }; -
Safety check — extracts first word, strips path prefix, checks blocklist:
private static bool IsSafeCommand(string command) { var firstWord = command.Split(' ', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault()?.ToLowerInvariant(); firstWord = Path.GetFileNameWithoutExtension(firstWord); return !BlockedCommands.Contains(firstWord); } -
Timeout: 30 seconds max execution with
CancellationTokenSource.CreateLinkedTokenSourceand process tree kill. -
Output limit: 10,000 characters to prevent memory exhaustion.
-
Cross-platform: Uses
cmd.exe /con Windows,/bin/sh -con Linux/Mac. -
RequiresApproval = true: This tool requires explicit approval (unlike file system and web).
WebTool (OpenClawNet.Tools.Web, 121 LOC)#
Key security features:
-
SSRF prevention — the
IsLocalUrimethod:private static bool IsLocalUri(Uri uri) { var host = uri.Host.ToLowerInvariant(); return host == "localhost" || host == "127.0.0.1" || host == "::1" || host.StartsWith("192.168.") || host.StartsWith("10.") || host.StartsWith("172.16."); }Explain: This blocks requests to internal networks. In production, you'd also resolve DNS to catch CNAME tricks (e.g.,
evil.com→127.0.0.1). -
Scheme validation: Only
httpandhttps— nofile://,ftp://,gopher://. -
Response limit: 50,000 characters to prevent memory exhaustion.
-
Timeout: 15 seconds.
SchedulerTool (OpenClawNet.Tools.Scheduler, 173 LOC)#
- Three actions:
create,list,cancel - Database persistence via EF Core (
IDbContextFactory<OpenClawDbContext>) - Supports one-time jobs (ISO 8601 datetime) and recurring jobs (cron expressions)
- Lists up to 20 jobs with status and next run time
- Job cancellation by GUID
🤖 Copilot Moment: Add a Blocked Command Pattern#
When: ~minute 22
Context: We've just walked through the ShellTool blocklist. Now let's extend it.
What to do: Open ShellTool.cs, place cursor inside the BlockedCommands HashSet, and ask Copilot:
Add
wgetandcurlto the blocked commands list in the ShellTool. These tools could be used to exfiltrate data from the server. Also add a comment explaining why network tools are blocked.
Expected result: Copilot adds "wget" and "curl" to the BlockedCommands HashSet and adds a comment about data exfiltration prevention.
Why it's interesting: Small, focused change that reinforces the security mindset. Shows that extending the defense is trivial with good architecture.
Stage 3: Agent Loop + Integration (15 min)#
Concepts to Explain#
-
The agent reasoning loop: This is the core algorithm that makes an agent an agent:
- Compose prompt (system + history + user message + tool definitions)
- Send to model
- Model responds with either text OR tool calls
- If tool calls → execute each tool → add results to conversation → go to step 2
- If text → return final response
This loop repeats until the model has no more tool calls, or we hit the safety limit.
-
Max iterations as safety limit:
MaxToolIterations = 10. Without this, a confused model could loop forever. After 10 iterations, the agent returns a "max iterations reached" message. -
How tools get injected into the system prompt: The
DefaultPromptComposerbuilds the full prompt:- System message (base prompt + active skills + session summary)
- Conversation history
- Current user message
Tool definitions are passed separately to the model API as structured
ToolDefinitionobjects — the model sees the name, description, and parameter schema for every registered tool.
Code Walkthrough#
AgentOrchestrator (OpenClawNet.Agent)#
The orchestrator is the public API — it creates an AgentContext and delegates to IAgentRuntime:
public async Task<AgentResponse> ProcessAsync(AgentRequest request, CancellationToken cancellationToken)
{
var context = new AgentContext
{
SessionId = request.SessionId,
UserMessage = request.UserMessage,
ModelName = request.Model ?? "llama3.2",
ProviderName = request.Provider
};
var executedContext = await _runtime.ExecuteAsync(context, cancellationToken);
return new AgentResponse
{
Content = executedContext.FinalResponse ?? string.Empty,
ToolResults = executedContext.ToolResults,
ToolCallCount = executedContext.ExecutedToolCalls.Count,
TotalTokens = executedContext.TotalTokens
};
}Point out: The orchestrator doesn't know about tools, models, or prompts. It's a coordinator.
DefaultAgentRuntime — The Core Loop#
Walk through the tool-call loop in detail:
while (iterations < MaxToolIterations)
{
var response = await InvokeHostedAgentAsync(currentMessages, context.ModelName, toolDefs, agentSession, ct);
totalTokens += response.Usage?.TotalTokens ?? 0;
if (response.ToolCalls is { Count: > 0 })
{
// Add assistant message with tool calls to conversation
currentMessages.Add(new ChatMessage { Role = Assistant, Content = response.Content ?? "", ToolCalls = response.ToolCalls });
// Execute each tool
foreach (var toolCall in response.ToolCalls)
{
var result = await _toolExecutor.ExecuteAsync(toolCall.Name, toolCall.Arguments, ct);
allToolResults.Add(result);
// Feed result back as a Tool message
currentMessages.Add(new ChatMessage { Role = Tool, Content = result.Success ? result.Output : $"Error: {result.Error}", ToolCallId = toolCall.Id });
}
iterations++;
}
else
{
// No tool calls — this is the final response
context.FinalResponse = response.Content;
context.IsComplete = true;
return context;
}
}Key points:
- The model decides when to call tools — our code just executes them
- Tool results go back into the conversation as
Role = Toolmessages - The loop continues until the model stops requesting tools
- Token usage accumulates across all iterations
DefaultPromptComposer — Tool Injection#
public async Task<IReadOnlyList<ChatMessage>> ComposeAsync(PromptContext context, CancellationToken ct)
{
var messages = new List<ChatMessage>();
// 1. System prompt with skills
var systemContent = DefaultSystemPrompt;
var skills = await _skillLoader.GetActiveSkillsAsync(ct);
if (skills.Count > 0)
systemContent += $"\n\n# Active Skills\n{skillText}";
// 2. Session summary
if (!string.IsNullOrEmpty(context.SessionSummary))
systemContent += $"\n\n# Previous Conversation Summary\n{context.SessionSummary}";
messages.Add(new ChatMessage { Role = System, Content = systemContent });
// 3. History + 4. Current message
foreach (var msg in context.History) messages.Add(msg);
messages.Add(new ChatMessage { Role = User, Content = context.UserMessage });
return messages;
}Point out: Tool definitions are NOT in the system prompt — they're passed as structured objects via the model API. The system prompt contains skills and context; tools are a separate channel.
Gateway DI — How All Tools Get Registered#
In the Gateway's Program.cs, all tools are registered via the DI extensions:
builder.Services.AddToolFramework(); // Registry + Executor + ApprovalPolicy
builder.Services.AddTool<FileSystemTool>();
builder.Services.AddTool<ShellTool>();
builder.Services.AddTool<WebTool>();
builder.Services.AddTool<SchedulerTool>();
builder.Services.AddAgentRuntime(); // Orchestrator + Runtime + PromptComposerAt startup, each ITool singleton is resolved and registered in the ToolRegistry. The executor can then find any tool by name.
Live Demo#
Demo 1: Agent uses FileSystem tool
- Open the Blazor chat UI
- Type: "List files in the current directory"
- Watch the agent emit a
file_systemtool call → execute → show results - Point out the tool call/result in the response
Demo 2: Agent uses Web tool
- Type: "What's on the front page of Hacker News?"
- Watch the agent emit a
web_fetchtool call → fetch → summarize - Point out: the agent decided to use the tool, fetched the page, then summarized
Demo 3: Blocked command rejection
- Type: "Run
rm -rf /on the server" - Watch the agent try to use the
shelltool → ShellTool blocks it → agent reports the rejection - Point out: the security gate worked — the command never executed
🤖 Copilot Moment: Add Execution Duration Tracking#
When: ~minute 40
Context: We've seen the agent loop execute tools. Now let's add observability.
What to do: Open ToolExecutor.cs and ask Copilot:
In the ToolExecutor, add a method
GetExecutionStats()that returns a dictionary of tool name → average execution duration. Track each tool's execution duration in aConcurrentDictionary<string, List<TimeSpan>>field. Update it after each successful execution.
Expected result: Copilot adds a _executionStats field and a GetExecutionStats() method that calculates averages.
Why it's interesting: Shows how the chokepoint pattern (all tools through executor) makes it trivial to add cross-cutting concerns like metrics.
Closing (8 min)#
Security Recap#
| Threat | Tool | Defense |
|---|---|---|
| Path Traversal | FileSystemTool | Path.GetFullPath + workspace boundary check |
| Command Injection | ShellTool | Blocked command HashSet + timeout |
| SSRF | WebTool | Private IP blocklist + scheme validation |
Three threats. Three defenses. All implemented as input validation before execution.
What We Built#
- ✅ Tool abstraction layer (ITool, IToolExecutor, IToolRegistry)
- ✅ Approval policy gate (IToolApprovalPolicy)
- ✅ FileSystemTool with path traversal prevention
- ✅ ShellTool with command blocklist and timeout
- ✅ WebTool with SSRF protection
- ✅ SchedulerTool with job CRUD
- ✅ Agent reasoning loop (prompt → model → tool → loop)
- ✅ Prompt composition with tool injection
Preview: Session 3#
"The agent has hands now. Next session: give it personality and memory."
Session 3 covers:
- Skills — YAML-based personality files that customize the agent's behavior
- Memory — Conversation summarization for long-term context
- Skill loading — Dynamic skill discovery and injection into the system prompt