Spec: Agent Tooling
- Status: Draft
- Last amended: 2026-06-16 (web.search tool — ADR-0033
webnamespace activation) - Constrained by: ADR-0004, ADR-0008, ADR-0009, ADR-0010, ADR-0011, ADR-0022, ADR-0033, ADR-0034, ADR-0043
- Implements:
packages/agent-tooling/,packages/natives/, daemon tool handlers inpackages/daemon/src/runtime/tool-handlers/
Purpose
This spec defines the agent tooling layer: the daemon subsystem that provides tools to the primary agent and subagents for interacting with the project workspace. It owns the tool registry, tool dispatch, and the concrete implementations of built-in tools across three domains:
- File & search tools — read, write, create, find, grep, and AST-aware structural operations on source files. Content editing via exact-string replacement (
edit.text). Adopted from oh-my-pi's Rust implementations for speed. - Code intelligence (
code.lsp) — diagnostics, go-to-definition, find-references, symbols, rename, and code actions via the Language Server Protocol. A singlecode.lsptool with action-based dispatch. LSP is a first-class citizen alongside linters, not an afterthought. - Debugger (
debug) — breakpoints, stepping, stack inspection, variable evaluation, and conditional watches via the Debug Adapter Protocol. A singledebugtool with action-based dispatch. Real debugging — notconsole.log.
This document is normative for:
- The tool registry — how tools are declared, discovered, and versioned.
- The tool dispatch protocol — how agents request tools and receive results within the session's run model.
- The built-in tool catalog — every tool's name, parameters, return shape, and behavior.
- The LSP bridge — how language servers are spawned, multiplexed, and exposed as actions of the
code.lsptool. - The DAP bridge — how debug adapters are spawned, session-scoped, and exposed as actions of the
debugtool. - The file tool implementations — which oh-my-pi tools are adopted, which are adapted, and which are built fresh.
- The sandbox integration — how tools interact with cage policies, seccomp profiles, and filesystem allowlists.
It is not normative for:
- The session manager's run model or tool_calls table schema (that's
session-manager.md). - The sandbox mechanism itself (that's
sandbox.md). - The plugin host's tool-exposure mechanism (that's
plugin-host.md— plugins expose their own methods; this spec covers built-in daemon tools). - The WebSocket framing of tool calls/results (that's
http-api.md). - The task runner (that's
task-runner.md— operator-initiated project tasks, not agent-callable tools).
Constraints (from ADRs)
| Constraint | Source |
|---|---|
Daemon runtime is Bun; Rust tools integrated via FFI (bun:ffi) or compiled sidecar binaries |
ADR-0004 |
| Subagent tools are daemon-mediated; subagents in cages cannot directly access host resources | ADR-0009 |
default seccomp blocks ptrace; DAP attachment requires relaxed seccomp on the target cage |
ADR-0009 |
Plugins always run with default seccomp; no relaxed for plugins |
ADR-0008 |
| All file paths are project-root-relative; tools enforce the same boundary | ADR-0011 |
| Tools must work under both deployment modes (per-user UID or system-wide kaged UID) | ADR-0010 |
Architecture
Why built-in, not plugin-provided
Three factors push these tools into the daemon core rather than the plugin boundary:
- Performance. File reads, searches, and edits are the most frequently called tools in any coding session. Every call through the plugin JSON-RPC boundary pays serialization, IPC, and process-switch costs. Built-in tools eliminate this.
- Sandbox mediation. Caged subagents cannot touch the host filesystem directly. The daemon must broker every file operation against the cage's
fsallowlist. Built-in tools enforce this at the call site — no capability-overreach possible. - Seccomp constraints. DAP requires
ptrace(blocked bydefaultseccomp). Plugins are locked todefaultseccomp with norelaxedoption (plugin-host.md). LSP servers need to execute arbitrary compilers/interpreters. Neither fits the plugin sandbox model.
Component layout
┌──────────────────────────────────────┐
│ Session Manager │
│ (dispatches tool calls from runs) │
└──────────────┬───────────────────────┘
│ tool_call(name, params, caller_context)
▼
┌──────────────────────────────────────┐
│ ToolRegistry │
│ built-in tools + plugin-declared │
│ tools, unified dispatch │
└───┬──────────┬──────────┬────────────┘
│ │ │
┌────────▼──┐ ┌───▼────┐ ┌──▼──────────┐
│ FileTools │ │ LSP │ │ DAP │
│ (Rust FFI) │ │ Bridge │ │ Bridge │
└────────────┘ └───┬────┘ └──┬──────────┘
│ │
┌─────▼──┐ ┌───▼──────────┐
│LS Pool │ │ DA Pool │
│(per- │ │(per-session │
│project)│ │ per-runtime) │
└────────┘ └──────────────┘
Five subsystems in packages/agent-tooling/:
ToolRegistry— the catalog of all available tools (built-in and plugin-declared). Resolves tool names, validates parameters, enforces per-caller permissions, dispatches calls.FileTools— file read/write/edit, search (grep, glob), and AST-aware operations. Rust implementations called via Bun FFI.LSPBridge— spawns and manages language server processes, translates agent tool calls into LSP JSON-RPC, returns structured results.DAPBridge— spawns and manages debug adapter processes, translates agent tool calls into DAP JSON messages, manages debug sessions.ToolPermissions— enforces cage-derived access control on every tool call. A subagent in a cage withfs: [{mode: ro, path: ./src}]cannot callfile.writeon./data/.
Tool registry
Registration
Tools are registered at daemon startup. Built-in tools are always present. Plugin-declared tools are registered when a plugin completes its initialize handshake (per plugin-host.md).
interface ToolDefinition {
name: string; // dot-delimited, e.g. "file.read", "mcp.github.issue.create"
namespace: "file" | "code" | "debug" | "search" | "shell" | "mcp" | string; // grouping
description: string; // shown in tool-use prompts
parameters: JSONSchema; // input validation
returns: JSONSchema; // output shape documentation
requires: ToolRequirement[]; // cage/permission prerequisites
source: "builtin" | "plugin" | "mcp";
plugin_name?: string; // if source is "plugin"
mcp_server?: string; // if source is "mcp" — DSL key of the MCP server
mcp_tool_name?: string; // if source is "mcp" — tool name as the MCP server knows it
principal_scope?: "root-only"; // if set, schema rejects this tool on non-root agents
}
Namespaces
| Namespace | Owner | Tools |
|---|---|---|
file |
FileTools (built-in) | file.read, file.write, file.create |
edit |
FileTools (built-in) | edit.text |
search |
FileTools (built-in) | search.grep, search.glob, search.ast |
code |
LSPBridge (built-in) | code.lsp (actions: diagnostics, definition, references, symbols, rename, rename_file, code_actions, hover, type_definition, implementation, status, reload, capabilities, request) |
debug |
DAPBridge (built-in) | debug (actions: launch, attach, set_breakpoint, remove_breakpoint, list_breakpoints, set_instruction_breakpoint, remove_instruction_breakpoint, data_breakpoint_info, set_data_breakpoint, remove_data_breakpoint, step_into, step_over, step_out, continue, pause, stack_trace, threads, scopes, variables, evaluate, disassemble, read_memory, write_memory, modules, loaded_sources, custom_request, disconnect) |
shell |
PtyBroker (built-in) | shell.bash — execute shell commands via PTY broker |
web |
Web search (built-in) | web.search — multi-provider web search with sequential fallback chain. Off by default; per-agent opt-in via tools: block. |
skill |
Declarative agent capabilities (built-in) | skill.use — on-demand skill activation. Dynamically registered per-agent only when the agent has discovered skills. Not in the global builtin set. |
kaged (checkpoint) |
Built-in (daemon) | kaged.checkpoint — model-initiated checkpoint (no principal_scope restriction) |
kaged (issue) |
Built-in (daemon) | kaged.issue — unified action-dispatched issue management (list, get, create, comment, transition) — principal_scope: "root-only" |
kaged (todo) |
Built-in (daemon) | kaged.todo — unified action-dispatched issue-bound todo management (view, set, add, start, done, drop, note) — principal_scope: "root-only" |
kaged (interaction) |
Built-in (daemon) | kaged.ask, kaged.form — structured operator interaction, checkpoint-like pause/resume — principal_scope: "root-only" |
kaged.workflow |
Built-in (daemon) | kaged.workflow.trigger, kaged.workflow.list, kaged.workflow.status — principal_scope: "root-only" (spec only, not implemented) |
<plugin>.* |
Plugin | Plugin-declared methods, proxied through the plugin host |
mcp.<server-key>.* |
MCP (ADR-0043) | External protocol adapters — tools discovered dynamically via MCP tools/list, proxied by the daemon |
Built-in namespace names (file, edit, search, code, debug, shell, compute, project, kaged, mcp) cannot be used by plugins. Per ADR-0033, additional namespaces (web, discover, media, job) are also reserved. Plugin methods follow the existing naming rules from plugin-host.md. The mcp namespace is reserved per ADR-0043 — built-in tools and plugins cannot claim it.
Tools carrying principal_scope: "root-only" are rejected by the DSL schema if an operator attempts to enable them on any non-root agent. See project-dsl.md § Tool resolution.
Dispatch
When the primary or a subagent emits a tool call:
- Resolve. The registry looks up the tool by name. Unknown tool → error
-32601(tool_not_found). - Validate params. Input validated against the tool's
parametersschema. Invalid → error-32602(invalid_params). - Authorize. The
ToolPermissionsmodule checks the caller's cage policy against the tool'srequires. Denied → error-32001(capability_denied). - Execute. Dispatch to the owning subsystem: FileTools, LSPBridge, DAPBridge, plugin host, or MCP client proxy (for
source: "mcp"tools). - Return. Result (or error) returned to the session manager, which records it in
tool_callsand streams it to the WebSocket. For MCP tools, result size truncation is applied before return (see MCP result truncation).
Caller context
Every tool call carries the caller's identity and sandbox context:
interface ToolCallContext {
session_id: string;
run_id: string;
caller: string; // tree-position path: "primary", "primary.subagents.scraper", etc.
project_id: string;
project_root: string; // absolute host path
cage_policy: CagePolicy | null; // null for uncaged agents (cage: disabled)
request_id: string; // for audit correlation
}
The caller field encodes the agent's position in the AgentSpec tree (e.g. "primary", "primary.subagents.scraper", "primary.subagents.builder.subagents.linter"). This path is used for audit logging, tool resolution (root-only tools check caller === "primary"), and permission scoping.
The cage_policy is the effective policy the caller is running under. Tools use it to enforce access boundaries without consulting the sandbox directly — the policy is the single source of truth. Agents with cage: disabled have cage_policy: null.
Per-agent tool resolution
Per ADR-0022, tools are resolved per agent, not per project. There is no project-level tools: block. Each agent in the AgentSpec tree carries its own tools: override map (or omits it to accept defaults).
Resolution chain (evaluated once at session start, per agent):
Built-in registry. All built-in tools (
file.*,edit.*,search.*,code.lsp,debug,kaged.issue.*,kaged.workflow.*), plugin-declared tools, and MCP-discovered tools (mcp.<server-key>.*) exist in the global registry. Existence in the registry does not mean availability — it means the tool can be enabled.Role-based defaults. The root agent (the
AgentSpecatProjectDsl.primary) receiveskaged.issue.*andkaged.workflow.*enabled by default. Every other agent in the tree starts with no tools enabled. The operator opts in per agent via the agent'stools:block.Agent's
tools:block. ARecord<string, ToolOverride>on theAgentSpec. Each key is a tool name or glob pattern; the value is{ enabled: true }or{ enabled: false }(to suppress a role-based default). Glob patterns are expanded against the registry at resolution time.principal_scopeenforcement. Tools withprincipal_scope: "root-only"(currentlykaged.issue.*andkaged.workflow.*) are rejected by the DSL parser if an operator attempts to enable them on any non-root agent. This is a schema-level rejection, not a runtime check — the project fails to load.Cage filter at dispatch. Even after an agent's tool set is resolved, every tool call passes through
ToolPermissionsat dispatch time. A tool that is enabled but whoserequiresentries are not satisfied by the agent's cage is rejected withcapability_denied. This is the runtime complement to the schema-level resolution.
Example resolution:
# Root agent — gets kaged.issue.* and kaged.workflow.* by default
primary:
model: smart-generalist
system_prompt: project:/prompts/primary.md
cage: disabled
# tools: omitted → role-based defaults only (kaged.issue.*, kaged.workflow.*)
subagents:
coder:
model: smart-fast
system_prompt: project:/prompts/coder.md
cage:
fs: [{ mode: rw, path: ./src }]
net: { allow: [] }
state: ephemeral
tools:
"file.*": { enabled: true }
"search.*": { enabled: true }
"lsp.*": { enabled: true }
# kaged.issue.create: { enabled: true } ← PARSE ERROR: root-only
In this example, the root agent can create/update issues and trigger workflows. The coder subagent can read/write files, search, and use LSP — but cannot touch issues or workflows. The coder's file.write calls are further constrained by its cage: writes outside ./src are rejected at dispatch time.
Synthetic agent-{key} tools. When the harness compiles the AgentSpec tree, each agent's direct children are registered as synthetic tools named agent-{key} on the parent (see agent.md). These synthetic tools do not appear in the tools: override map — they are always available to the parent. The operator controls the call graph by controlling the tree structure, not by toggling synthetic tools.
MCP tool source (ADR-0043)
MCP (Model Context Protocol) is a third tool source alongside built-in and plugin. MCP servers expose tools via a standardised protocol (tools/list, tools/call). The daemon acts as the MCP client — it discovers tools at runtime and proxies dispatch. Unlike plugins, MCP servers implement their own protocol; no kaged SDK is required.
Registration
At session-start, the daemon calls tools/list on each configured MCP server (per the project DSL's mcp: block). Each discovered tool is registered with:
- Name:
mcp.<server-key>.<tool-name>— themcpnamespace is reserved (ADR-0033 amendment). - Source:
"mcp"— distinguishes from built-in and plugin tools. - Parameters: from the MCP tool's
inputSchema(JSON Schema). - Description: from the MCP tool's metadata.
The tools.allow/deny filter patterns from the DSL's mcp.<server-key>.tools block are applied at registration time — only matching tools are registered. Registration failures (server unreachable, tools/list error) mark the server as failed; the session starts with other MCP servers active.
MCP tools are not root-only. Any agent can be granted MCP tools via its tools: block, subject to the standard per-agent resolution.
Dispatch
Agent calls mcp.github.issue.create → registry resolves source: "mcp" → daemon proxies to the MCP server's tools/call endpoint → result returned through the standard tool-result path (with size truncation applied).
The dispatch path for MCP tools:
- Resolve — registry lookup (same as all tools).
- Validate params — against the MCP tool's
inputSchema(same as all tools). - Authorize — cage policy check. MCP tools have no
requiresentries (they are daemon-proxied, not cage-mediated). Authorization always passes. - Execute — the daemon's MCP client sends a
tools/callJSON-RPC request to the MCP server. Forstdioservers, this is stdin/stdout. Forsseservers, this is HTTP POST. - Return — result is processed (truncation applied) and returned through the standard tool-result path.
MCP result truncation
MCP tool results can be arbitrarily large. The daemon enforces a configurable cap on all MCP tool results.
- Default: 64 KB (65,536 bytes).
- Configurable at: daemon operational config (
config.toml), not per-server or per-tool. One knob for all MCP servers. - Behaviour: Results exceeding the cap are truncated to the limit and appended with a truncation marker:
\n[... truncated at 65536 bytes]. The tool result'struncatedfield is set totrue.
MCP sampling rejection
MCP servers can request the client (kaged) to perform LLM sampling (sampling/createMessage). This is a fundamental trust boundary — allowing external code to trigger LLM calls means MCP servers can spend the operator's tokens and influence agent reasoning. The daemon rejects all sampling/createMessage requests from MCP servers with an error response. This is non-negotiable per ADR-0043.
What is explicitly NOT supported
- MCP resources — structured data exposed by MCP servers. Deferred to v2.
- MCP prompts — prompt templates. Deferred to v2.
- MCP server version pinning —
min_versionsupport. Not implemented. streamable-httptransport — deferred to Phase 2.
See ADR-0043 for the full decision record and phasing.
File & search tools
Provenance
These tools are adopted from oh-my-pi's Rust implementations. oh-my-pi's file tools are battle-tested across millions of coding sessions. We adopt the Rust core and wrap it with kaged's permission layer.
| oh-my-pi tool | kaged tool | Adoption strategy |
|---|---|---|
read |
file.read |
Adopt. Rust binary, called via Bun FFI. Line-numbered output, offset/limit pagination. |
write |
file.write |
Adopt. Full-file write with content. |
edit |
edit.text |
Adopt. Hashline-anchored patching with stale-anchor recovery. |
ast_edit |
search.ast / edit.ast |
Adapt. Adopt the ast-grep search (search.ast); structural rewrite exposed as edit.ast (dry-run by default, requires dry_run: false to apply). |
search (grep) |
search.grep |
Adopt. Regex content search with file-pattern filtering. |
find (glob) |
search.glob |
Adopt. Fast glob matching with modification-time sorting. |
bash |
(not adopted) | Covered by the PTY broker and task runner. Agents use terminal access or operator-initiated tasks. |
eval |
(not adopted) | Security concern; arbitrary code eval inside the daemon is against kaged's threat model. |
lsp |
code.lsp |
Build fresh. oh-my-pi's LSP tool is a monolith; kaged exposes a single code.lsp tool with action-based dispatch and a managed server pool. |
debug |
debug |
Build fresh. oh-my-pi's debug tool wraps DAP; kaged builds a session-scoped DAP bridge exposed as a single debug tool with action-based dispatch. |
Integration strategy
The Rust implementations are compiled as shared libraries (.so / .dylib) and loaded via bun:ffi. Each tool function is a synchronous or async FFI call that takes a path and parameters, returns structured JSON.
// Conceptual — actual FFI bindings in packages/agent-tooling/ffi/
import { dlopen, FFIType } from "bun:ffi";
const lib = dlopen("libkaged_filetools.so", {
file_read: {
args: [FFIType.ptr, FFIType.i32, FFIType.i32], // path, offset, limit
returns: FFIType.ptr, // JSON result
},
search_grep: {
args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], // pattern, path, include
returns: FFIType.ptr,
},
// ...
});
If FFI proves fragile across platforms, the fallback is a compiled Rust sidecar binary (kaged-filetools) that the daemon communicates with over a Unix socket. The tool API shape is identical either way.
Tool: file.read
Read a file or directory listing.
Parameters:
{
"path": "./src/main.ts",
"offset": 1,
"limit": 200
}
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Project-relative path to read. |
offset |
integer | no | Line number to start from (1-indexed). Default: 1. |
limit |
integer | no | Max lines to return. Default: 2000. |
Returns:
{
"path": "./src/main.ts",
"type": "file",
"content": "1: import { serve } from 'bun';\n2: ...",
"total_lines": 42,
"truncated": false
}
For directories, returns an entry listing (one per line, trailing / for subdirectories).
Permission: requires read:fs on the resolved path. Always allowed for the primary (no cage). For caged subagents, the path must fall inside an fs entry with mode ro or rw.
Behavior:
- Lines are prefixed with their line number (
N: content). - Lines longer than 2000 characters are truncated with a
[truncated]marker. - Binary files return
{"type": "binary", "size": N, "mime": "..."}instead of content. - If
pathdoes not exist, returns errorfile_not_found. - Symlinks are resolved; the resolved path must still be within the cage's allowlist.
Tool: file.write
Write (create or overwrite) a file.
Parameters:
{
"path": "./src/new-module.ts",
"content": "export function greet() { ... }"
}
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Project-relative path. |
content |
string | yes | Full file content. |
Returns:
{
"path": "./src/new-module.ts",
"bytes_written": 1234,
"created": true
}
Permission: requires write:fs on the resolved path (cage fs entry with mode: rw). The primary can write anywhere in the project root.
Behavior:
- Creates parent directories if they don't exist.
- Overwrites existing files entirely — no merge.
- The agent MUST have read the file first (tracked by the tool registry) before overwriting. If the agent hasn't read the file, the tool returns error
file_not_readwith message "Read the file before overwriting it." This prevents blind overwrites.
Tool: edit.text
Apply a targeted edit to a file using exact string matching.
Parameters:
{
"path": "./src/main.ts",
"old_string": "const port = 3000;",
"new_string": "const port = parseInt(process.env.PORT ?? '3000');",
"replace_all": false
}
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Project-relative path. |
old_string |
string | yes | Exact text to find. |
new_string |
string | yes | Replacement text. Must differ from old_string. |
replace_all |
boolean | no | Replace all occurrences. Default: false. |
Returns:
{
"path": "./src/main.ts",
"replacements": 1
}
Permission: requires write:fs on the resolved path.
Behavior:
old_stringnot found → errorold_string_not_found.old_stringfound multiple times andreplace_allis false → errormultiple_matcheswith count.old_string == new_string→ errorno_change.- Preserves file encoding (UTF-8) and line endings.
- The agent MUST have read the file first.
Tool: file.create
Create a new file. Fails if the file already exists.
Parameters:
{
"path": "./src/utils/helpers.ts",
"content": "export function clamp(n: number, min: number, max: number) { ... }"
}
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | Project-relative path. |
content |
string | yes | File content. |
Returns:
{
"path": "./src/utils/helpers.ts",
"bytes_written": 256,
"created": true
}
Permission: requires write:fs on the resolved path.
Behavior:
- If file exists → error
file_exists. Usefile.writeto overwrite. - Creates parent directories if needed.
Tool: search.grep
Search file contents using regular expressions.
Parameters:
{
"pattern": "TODO|FIXME",
"path": "./src",
"include": "*.ts",
"output_mode": "content",
"head_limit": 50
}
| Param | Type | Required | Description |
|---|---|---|---|
pattern |
string | yes | Regex pattern. |
path |
string | no | Directory to search. Default: project root. |
include |
string | no | File glob filter (e.g., *.ts, *.{ts,tsx}). |
output_mode |
enum | no | content (matching lines), files_with_matches (paths only), count. Default: files_with_matches. |
head_limit |
integer | no | Max results. Default: unlimited. |
Returns:
{
"matches": [
{ "file": "./src/main.ts", "line": 42, "content": "// TODO: handle errors" }
],
"total_matches": 1,
"truncated": false
}
Permission: requires read:fs on the search path. For caged subagents, results are filtered to only include files within the cage's readable mounts.
Behavior:
- Timeout: 60 seconds.
- Output cap: 256 KB. Truncated results include
"truncated": true. - Binary files are skipped.
.gitignorepatterns are respected by default.
Tool: search.glob
Find files by name pattern.
Parameters:
{
"pattern": "**/*.test.ts",
"path": "./src"
}
| Param | Type | Required | Description |
|---|---|---|---|
pattern |
string | yes | Glob pattern. |
path |
string | no | Directory to search. Default: project root. |
Returns:
{
"files": ["./src/auth.test.ts", "./src/db.test.ts"],
"count": 2,
"truncated": false
}
Permission: requires read:fs on the search path.
Behavior:
- Timeout: 60 seconds.
- File limit: 100. Truncated results include
"truncated": true. - Results sorted by modification time (most recent first).
.gitignorepatterns respected.
Tool: search.ast
Search code using AST-aware pattern matching via ast-grep.
Parameters:
{
"pattern": "console.log($MSG)",
"lang": "typescript",
"paths": ["./src"],
"context": 2
}
| Param | Type | Required | Description |
|---|---|---|---|
pattern |
string | yes | AST pattern with meta-variables ($VAR, $$$). Must be a complete AST node. |
lang |
enum | yes | Target language. Supported: typescript, javascript, tsx, python, rust, go, java, c, cpp, css, html, json, yaml, and others as ast-grep supports. |
paths |
list of strings | no | Directories to search. Default: project root. |
globs |
list of strings | no | Include/exclude globs. Prefix ! to exclude. |
context |
integer | no | Context lines around match. Default: 0. |
Returns:
{
"matches": [
{
"file": "./src/main.ts",
"line": 15,
"content": "console.log(\"server started\")",
"meta_variables": { "MSG": "\"server started\"" },
"context_before": [" const server = serve({"],
"context_after": [" });"]
}
],
"count": 1
}
Permission: requires read:fs on the search paths.
Behavior:
- Patterns must be complete AST nodes (valid code fragments). Invalid patterns → error
invalid_pattern. - Meta-variables:
$VARmatches a single node,$$$matches multiple nodes. - Uses the ast-grep Rust library via FFI.
Tool: edit.ast
Apply AST-aware structural rewrite rules to source files using ast-grep. Dry-run by default — agents must set dry_run: false to persist changes.
Parameters:
{
"rewrites": { "console.log($MSG)": "logger.debug($MSG)" },
"lang": "typescript",
"path": "./src",
"glob": "*.ts",
"dry_run": true
}
| Param | Type | Required | Description |
|---|---|---|---|
rewrites |
object | yes | Map of pattern string → replacement template. Each key is an ast-grep pattern with meta-variables; each value is the replacement template using the same variables. |
lang |
enum | no | Target language. Supported: typescript, javascript, tsx, python, rust, go, java, c, cpp, css, html, json, yaml, and others as ast-grep supports. Inferred from file extensions when omitted if all candidates share one language. |
path |
string | no | Single file or directory to rewrite. Default: project root. |
glob |
string | no | Optional glob filter within the search root. |
dry_run |
boolean | no | When true (default), computes changes without writing files. Returns a preview of all replacements. |
max_replacements |
integer | no | Cap on replacement applications across all files. Default: unlimited. |
max_files |
integer | no | Cap on distinct files that may be modified. Default: unlimited. |
Returns:
{
"changes": [
{
"path": "./src/main.ts",
"before": "console.log(\"server started\")",
"after": "logger.debug(\"server started\")",
"byte_start": 120,
"byte_end": 150,
"start_line": 15,
"start_column": 8,
"end_line": 15,
"end_column": 45
}
],
"file_changes": [
{ "path": "./src/main.ts", "count": 1 }
],
"total_replacements": 1,
"files_touched": 1,
"files_searched": 3,
"applied": false,
"limit_reached": false
}
Permission: requires write:fs on the resolved path when dry_run is false. Requires read:fs when dry_run is true.
Behavior:
- Dry-run by default (
"applied": false). Agents must explicitly setdry_run: falseto write changes to disk. - When
dry_run: true, the tool returns the full set of proposed replacements without modifying any files. - When
dry_run: false, the tool applies all non-overlapping edits and writes files back. - Parse or pattern errors on individual files are collected and returned in
parse_errorswithout failing the entire operation (unless the file is the only candidate). - Invalid patterns (unparseable ast-grep syntax) → error
invalid_pattern. - Language inference failure (mixed extensions, unsupported extension) → error
invalid_paramswith detail. - Uses the ast-grep Rust library via FFI, sharing the same parser infrastructure as
search.ast.
Code intelligence tool (code.lsp)
Design philosophy
LSP is a first-class citizen in kaged's agent tooling. Coding agents that can query diagnostics, navigate to definitions, and perform safe renames produce dramatically better code than agents that work blind.
The LSP bridge is not a passthrough — it translates LSP's chatty, stateful JSON-RPC protocol into discrete, stateless agent actions. An agent calls code.lsp with action: "diagnostics" and gets a clean result. The bridge manages the underlying language server lifecycle, initialization, file synchronization, and capability negotiation.
LSP bridge architecture
┌──────────────────────┐
│ Agent (primary │
│ or subagent) │
└──────────┬───────────┘
│ code.lsp({ action: "diagnostics", path: "./src/main.ts" })
▼
┌──────────────────────┐
│ ToolRegistry │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ LSPBridge │
│ ┌────────────────┐ │
│ │ ServerPool │ │
│ │ │ │
│ │ ts-server ─────│──│──▶ typescript-language-server
│ │ pyright ──────│──│──▶ pyright-langserver
│ │ rust-analyzer ─│──│──▶ rust-analyzer
│ │ ... │ │
│ └────────────────┘ │
│ │
│ ┌────────────────┐ │
│ │ FileSync │ │──── tracks open/changed files
│ └────────────────┘ │ for textDocument/didOpen etc.
└──────────────────────┘
Language server pool
The LSPBridge maintains a per-project pool of language servers. Servers are spawned on demand (first tool call for a given language in a project) and kept alive for the project's lifetime.
Server lifecycle:
- Spawn. On first
code.lspcall for a language, the bridge:- Resolves the language server binary from a configurable registry (see LSP server configuration).
- Spawns the server as a child process of the daemon, outside any subagent cage (the daemon is the LSP client).
- Performs the LSP
initialize/initializedhandshake. - Sends
workspace/didOpenfor files the agents have read so far.
- Active. The server processes requests. File changes made by agents via
file.write/edit.texttriggertextDocument/didChangenotifications. - Idle timeout. If no
code.lspcall is made for 10 minutes, the server is sentshutdown+exit. It is re-spawned on next use. - Teardown. On project unload or daemon shutdown, all servers for the project are shut down.
Why daemon-side, not cage-side:
- Language servers often need the full project tree (imports,
node_modules,tsconfig.json). Caged subagents may only see a slice. - Language servers are expensive to start. A single daemon-side instance serves all agents in a project.
- The daemon can enforce that agents only see diagnostics for files within their cage — the bridge filters results post-hoc.
LSP server configuration
The LSP bridge needs to know which language server to use for each language. Configuration lives in the project DSL (new optional field) and/or the operator's local config.
Project DSL extension (optional):
# in .kaged/project.yaml
lsp:
servers:
- lang: typescript
command: ["typescript-language-server", "--stdio"]
- lang: python
command: ["pyright-langserver", "--stdio"]
- lang: rust
command: ["rust-analyzer"]
Local config fallback:
# in config.toml
[lsp.servers.typescript]
command = ["typescript-language-server", "--stdio"]
[lsp.servers.python]
command = ["pyright-langserver", "--stdio"]
Resolution order: project DSL > local config > kaged built-in defaults.
Built-in defaults (v0):
| Language | Default server | Notes |
|---|---|---|
| TypeScript/JavaScript | typescript-language-server --stdio |
Wraps tsserver. |
| Python | pyright-langserver --stdio |
Fast, zero-config. |
| Rust | rust-analyzer |
Standard. |
| Go | gopls |
Standard. |
Operators install language servers on their host. kaged does not bundle them. If a server binary is not found, code.lsp actions for that language return error lsp_server_not_found with the expected command and install instructions.
File synchronization
The LSP protocol requires the client to notify the server about file opens and changes. The LSPBridge maintains a FileSync tracker:
file.readtriggerstextDocument/didOpenif the file hasn't been opened in the server yet.file.write/edit.texttriggerstextDocument/didChangewith the new content.- File sync is project-scoped, not agent-scoped. If subagent A edits a file, subagent B's next
code.lspdiagnosticscall sees the change.
This means LSP results are always current with the workspace state, even across concurrent subagent edits within the same session.
Tool: code.lsp
A single unified tool with action-based dispatch. The action parameter selects the LSP operation; action-specific parameters are flat siblings validated by the handler. All actions require read:fs on the path unless noted otherwise.
Common parameters (all actions):
| Param | Type | Required | Description |
|---|---|---|---|
action |
enum | yes | One of: diagnostics, definition, references, symbols, rename, code_actions, hover. |
path |
string | yes | File or directory path. Required for all actions. |
Position parameters (required for definition, references, rename, code_actions, hover):
| Param | Type | Required | Description |
|---|---|---|---|
line |
integer | conditional | Line number (1-indexed). Required for position-based actions. |
character |
integer | conditional | Column (0-indexed). Required for position-based actions. |
Permission: requires { kind: "fs", mode: "rw", path: "caller" }. Results are filtered to files within the caller's cage allowlist where applicable.
Action: diagnostics
Get errors, warnings, and hints for a file or directory.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
severity |
enum | no | Filter: error, warning, information, hint, all. Default: all. |
Example:
{
"action": "diagnostics",
"path": "./src/main.ts",
"severity": "error"
}
Returns:
{
"path": "./src/main.ts",
"diagnostics": [
{
"line": 15,
"character": 8,
"end_line": 15,
"end_character": 12,
"severity": "error",
"message": "Property 'naem' does not exist on type 'User'. Did you mean 'name'?",
"code": "ts(2551)",
"source": "typescript"
}
],
"count": 1
}
Behavior:
- Language is auto-detected from file extension.
- If no language server is running for the detected language, one is spawned (may take a few seconds on first call).
- For directories, the tool scans files matching the language's default extensions and returns aggregated diagnostics.
- Timeout: 30 seconds (language servers can be slow on first analysis).
Action: definition
Jump to the definition of a symbol.
Example:
{
"action": "definition",
"path": "./src/main.ts",
"line": 15,
"character": 8
}
Returns:
{
"definitions": [
{
"path": "./src/types.ts",
"line": 3,
"character": 0,
"preview": "export interface User {"
}
]
}
Permission: requires read:fs on both the source file and the definition target. Definitions in files outside the caller's cage are returned with path only (no preview).
Action: references
Find all usages of a symbol across the workspace.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
include_declaration |
boolean | no | Include the declaration itself. Default: false. |
Example:
{
"action": "references",
"path": "./src/types.ts",
"line": 3,
"character": 17,
"include_declaration": false
}
Returns:
{
"references": [
{ "path": "./src/main.ts", "line": 15, "character": 8, "preview": "const user: User = ..." },
{ "path": "./src/auth.ts", "line": 42, "character": 12, "preview": "function validateUser(u: User)" }
],
"count": 2
}
Permission: read:fs on source. References outside the caller's cage are included (read-only information about the broader codebase is safe).
Action: symbols
Get symbols from a file (document outline) or search across the workspace.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
scope |
enum | no | document (file outline) or workspace (project-wide search). Default: document. |
query |
string | conditional | Required for workspace scope. Symbol name to search. |
limit |
integer | no | Max results. Default: 50. |
Example:
{
"action": "symbols",
"path": "./src/main.ts",
"scope": "document",
"limit": 50
}
Returns:
{
"symbols": [
{ "name": "serve", "kind": "function", "path": "./src/main.ts", "line": 5, "character": 0 },
{ "name": "handleRequest", "kind": "function", "path": "./src/main.ts", "line": 12, "character": 0 }
],
"count": 2
}
Action: rename
Rename a symbol across the workspace.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
new_name |
string | yes | New symbol name. |
Example:
{
"action": "rename",
"path": "./src/types.ts",
"line": 3,
"character": 17,
"new_name": "AppUser"
}
Returns:
{
"changes": [
{ "path": "./src/types.ts", "edits": 1 },
{ "path": "./src/main.ts", "edits": 3 },
{ "path": "./src/auth.ts", "edits": 2 }
],
"total_edits": 6,
"files_changed": 3
}
Permission: requires write:fs on all files that would be changed. If any file falls outside the caller's cage, the rename is rejected with error rename_scope_exceeds_cage listing the inaccessible files. The agent must request a broader cage or delegate to the primary.
Behavior:
- The bridge first calls
textDocument/prepareRenameto validate the rename is possible. - If preparation fails (not a renameable symbol), returns error
rename_not_possible. - On success, all edits are applied atomically. If any file write fails, the entire rename is rolled back.
Action: code_actions
Get available code actions (quick fixes, refactors) for a location.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
include_diagnostics |
boolean | no | Include diagnostic-associated actions. Default: true. |
Example:
{
"action": "code_actions",
"path": "./src/main.ts",
"line": 15,
"character": 8,
"include_diagnostics": true
}
Returns:
{
"actions": [
{
"title": "Change spelling to 'name'",
"kind": "quickfix",
"preferred": true,
"edits": [
{ "path": "./src/main.ts", "line": 15, "old": "naem", "new": "name" }
]
}
],
"count": 1
}
Applying actions: the agent selects an action and calls edit.text with the provided edits. Code actions are informational — the agent decides whether to apply them.
Action: hover
Get hover information (type info, documentation) for a symbol.
Example:
{
"action": "hover",
"path": "./src/main.ts",
"line": 15,
"character": 8
}
Returns:
{
"content": "(property) User.name: string",
"documentation": "The user's display name.",
"range": { "start": { "line": 15, "character": 8 }, "end": { "line": 15, "character": 12 } }
}
Debugger tool (debug)
Design philosophy
Real debugging means breakpoints, stepping, stack inspection, and variable evaluation. Not console.log. kaged agents should debug like a senior engineer: set a breakpoint, run the program, inspect the state, hypothesize, adjust.
The DAP bridge translates the Debug Adapter Protocol's session-oriented model into a single debug tool with action-based dispatch. An agent calls debug with action: "launch" to start a debug session, action: "set_breakpoint" to set breakpoints, action: "continue" to run to the next breakpoint, and action: "variables" to inspect state.
DAP bridge architecture
┌──────────────────────┐
│ Agent │
└──────────┬───────────┘
│ debug({ action: "launch", runtime: "bun", script: "./src/main.ts" })
▼
┌──────────────────────┐
│ ToolRegistry │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ DAPBridge │
│ ┌────────────────┐ │
│ │ SessionPool │ │
│ │ │ │
│ │ ses_abc → DA ──│──│──▶ debug adapter process
│ │ ses_def → DA ──│──│──▶ debug adapter process
│ └────────────────┘ │
└──────────────────────┘
Debug sessions
A debug session is scoped to a kaged session (one run at a time). An agent can have at most one active debug session. Debug sessions are explicitly created (debug with action: "launch" / action: "attach") and explicitly ended (action: "disconnect").
Session lifecycle:
- Launch/Attach. The agent calls
debugwithaction: "launch"(start a new program under the debugger) oraction: "attach"(attach to a running process). The bridge spawns a debug adapter and initializes it per DAP spec. - Active. The agent sets breakpoints, runs the program, inspects state.
- Disconnect. The agent calls
debugwithaction: "disconnect". The bridge terminates the debug adapter and the debugged process. - Auto-cleanup. If the kaged run ends (completes, fails, or is cancelled) while a debug session is active, the bridge disconnects automatically.
Debug adapter configuration
Similar to LSP servers, debug adapters are configured per runtime:
Project DSL extension (optional):
# in .kaged/project.yaml
dap:
adapters:
- runtime: bun
command: ["bun", "--inspect-brk=0", "--inspect-wait"]
type: "bun"
- runtime: node
command: ["node", "--inspect-brk"]
adapter: ["js-debug-adapter"]
type: "pwa-node"
- runtime: python
adapter: ["debugpy", "--listen", "0"]
type: "debugpy"
Built-in defaults (v0):
| Runtime | Debug adapter | Launch mechanism |
|---|---|---|
| Bun (TypeScript/JS) | Built-in (--inspect-brk) |
Bun's native WebSocket debugger with CDP-to-DAP translation |
| Node.js | js-debug-adapter (VS Code's DAP adapter) |
Standard DAP over stdio |
| Python | debugpy |
Standard DAP |
Seccomp interaction
DAP debugging often requires ptrace for process attachment and stepping. This interacts with the sandbox:
| Scenario | Seccomp | Behavior |
|---|---|---|
| Primary agent debugging | No cage | Full DAP access. Debug adapter runs as daemon UID. |
Subagent in relaxed cage |
ptrace allowed |
DAP works. Debug adapter spawned inside the cage. |
Subagent in default cage |
ptrace blocked |
debug launch/attach actions return error dap_requires_relaxed_seccomp. Agent must escalate to primary or operator. |
Subagent with cage: disabled |
No seccomp | Full DAP access, same as primary. |
For Bun's --inspect debugger (WebSocket-based, not ptrace-based), the default seccomp profile is sufficient. The bridge detects the runtime and advises accordingly.
Tool: debug
A single unified tool with action-based dispatch. The action parameter selects the debug operation; action-specific parameters are flat siblings validated by the handler. At most one active debug session per agent.
Common parameter (all actions):
| Param | Type | Required | Description |
|---|---|---|---|
action |
enum | yes | One of: launch, attach, set_breakpoint, remove_breakpoint, list_breakpoints, step_into, step_over, step_out, continue, stack_trace, variables, evaluate, disconnect. |
Permission: requires { kind: "fs", mode: "ro", path: "caller" } and { kind: "capability", name: "exec" }. For caged subagents with default seccomp using non-WebSocket debuggers, returns error with guidance to use relaxed seccomp.
Action: launch
Start a program under the debugger.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
runtime |
string | yes | Runtime identifier (bun, node, python, go, etc.). |
script |
string | yes | Project-relative path to the entry point. |
args |
list of strings | no | Arguments to the program. |
env |
object | no | Environment variables (merged with cage env). |
stop_on_entry |
boolean | no | Pause on first line. Default: true. |
cwd |
string | no | Working directory (project-relative). Default: project root. |
Example:
{
"action": "launch",
"runtime": "bun",
"script": "./src/main.ts",
"args": ["--port", "3000"],
"env": { "NODE_ENV": "test" },
"stop_on_entry": true
}
Returns:
{
"debug_session_id": "dap_01HXAB",
"status": "stopped",
"stopped_reason": "entry",
"threads": [
{ "id": 1, "name": "main" }
]
}
Action: attach
Attach the debugger to a running process.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
runtime |
string | yes | Runtime identifier. |
port |
integer | yes | Debug port. |
host |
string | no | Host to connect to. Default: localhost. |
Example:
{
"action": "attach",
"runtime": "bun",
"port": 9229,
"host": "localhost"
}
Returns: same shape as launch.
Action: set_breakpoint
Add a breakpoint to a source location.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | File path. |
line |
integer | yes | Line number. |
condition |
string | no | Conditional expression (only break when true). |
hit_count |
integer | no | Break after N hits. |
log_message |
string | no | Log message instead of breaking (logpoint). |
Example:
{
"action": "set_breakpoint",
"path": "./src/main.ts",
"line": 42,
"condition": "user.role === 'admin'"
}
Returns:
{
"breakpoint_id": 1,
"verified": true,
"actual_line": 42,
"path": "./src/main.ts"
}
Action: remove_breakpoint
Remove a breakpoint from a source location.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
path |
string | yes | File path. |
line |
integer | yes | Line number. |
Example:
{
"action": "remove_breakpoint",
"path": "./src/main.ts",
"line": 42
}
Action: list_breakpoints
List all active breakpoints. No additional parameters.
Example:
{
"action": "list_breakpoints"
}
Returns:
{
"breakpoints": [
{ "id": 1, "path": "./src/main.ts", "line": 42, "condition": "user.role === 'admin'", "verified": true },
{ "id": 2, "path": "./src/auth.ts", "line": 15, "condition": null, "verified": true }
]
}
Actions: step_into, step_over, step_out
Step through code.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
thread_id |
integer | no | Thread to step. Default: current/main thread. |
Example:
{
"action": "step_over",
"thread_id": 1
}
Returns:
{
"status": "stopped",
"stopped_reason": "step",
"location": {
"path": "./src/main.ts",
"line": 43,
"column": 4,
"source_preview": " const result = await processRequest(req);"
}
}
Action: continue
Continue execution until the next breakpoint or program exit.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
thread_id |
integer | no | Thread to continue. Default: current/main thread. |
Example:
{
"action": "continue",
"thread_id": 1
}
Returns:
{
"status": "stopped",
"stopped_reason": "breakpoint",
"breakpoint_id": 2,
"location": {
"path": "./src/auth.ts",
"line": 15,
"column": 0,
"source_preview": "function validateToken(token: string) {"
}
}
If the program exits without hitting a breakpoint:
{
"status": "exited",
"exit_code": 0
}
Action: stack_trace
Get the call stack at the current pause point.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
thread_id |
integer | no | Thread ID. Default: current thread. |
levels |
integer | no | Max stack frames to return. Default: 20. |
Example:
{
"action": "stack_trace",
"thread_id": 1,
"levels": 10
}
Returns:
{
"frames": [
{ "id": 0, "name": "validateToken", "path": "./src/auth.ts", "line": 15, "column": 0 },
{ "id": 1, "name": "handleRequest", "path": "./src/main.ts", "line": 28, "column": 8 },
{ "id": 2, "name": "serve", "path": "./src/main.ts", "line": 5, "column": 0 }
],
"total_frames": 3
}
Action: variables
Inspect variables in the current scope.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
frame_id |
integer | no | Stack frame to inspect. Default: top frame (0). |
scope |
enum | no | local, closure, global. Default: local. |
filter |
string | no | Variable name filter (substring match). |
expand |
string | no | Variable name to expand (show nested properties). |
Example:
{
"action": "variables",
"frame_id": 0,
"scope": "local"
}
Returns:
{
"variables": [
{ "name": "token", "value": "\"eyJhbG...\"", "type": "string" },
{ "name": "user", "value": "User { name: \"alice\", role: \"admin\" }", "type": "User", "expandable": true },
{ "name": "isValid", "value": "true", "type": "boolean" }
]
}
With "expand": "user":
{
"variables": [
{ "name": "user.name", "value": "\"alice\"", "type": "string" },
{ "name": "user.role", "value": "\"admin\"", "type": "string" },
{ "name": "user.id", "value": "42", "type": "number" }
]
}
Action: evaluate
Evaluate an expression in the current debug context.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
expression |
string | yes | Expression to evaluate. |
frame_id |
integer | no | Stack frame context. Default: top frame. |
context |
enum | no | watch (side-effect-free), repl (may have side effects). Default: watch. |
Example:
{
"action": "evaluate",
"expression": "user.permissions.includes('write')",
"frame_id": 0,
"context": "watch"
}
Returns:
{
"result": "false",
"type": "boolean"
}
Security note: context: "repl" can execute arbitrary code in the debugged process. The tool warns agents to prefer "watch" for inspection. The cage's capabilities bound the blast radius — the debugged process is inside the cage, not the daemon.
Action: disconnect
End the debug session.
Additional parameters:
| Param | Type | Required | Description |
|---|---|---|---|
terminate_debuggee |
boolean | no | Kill the debugged program. Default: true. |
Example:
{
"action": "disconnect",
"terminate_debuggee": true
}
Returns:
{
"disconnected": true,
"debuggee_terminated": true
}
Project management tools (kaged.*)
Design philosophy
The kaged.issue.* and kaged.workflow.* namespaces give the root agent first-class access to the daemon's project management surface. A root agent coordinating subagents can file issues, update their status, trigger workflows, and check run results — all within the same reasoning loop that drives delegation.
These tools are root-only by design (ADR-0022 rule 5, rule 10). Subagents do not file issues or trigger workflows. Subagent work products bubble up to the root agent, which decides whether to update an issue or trigger a workflow based on the results. This keeps the audit trail single-rooted and prevents subagents from side-effecting project state.
All kaged.* tools carry principal_scope: "root-only". The DSL parser rejects them on non-root agents at load time. As a defense-in-depth measure, the registry also checks caller === "primary" at dispatch time and rejects with principal_scope_violation if a non-root caller somehow reaches the tool.
These tools do not require cage permissions (fs, net, seccomp) — they are daemon-internal API calls that read from and write to the daemon's SQLite database. The requires array is empty.
Tool: kaged.issue.list
List issues in the current project.
Parameters:
{
"status": "open",
"limit": 25,
"offset": 0
}
| Param | Type | Required | Description |
|---|---|---|---|
status |
enum | no | Filter by status: open, triaged, assigned, in_progress, resolved, rejected, reopened. Omit for all non-terminal statuses. |
limit |
integer | no | Max results. Default: 25. Max: 100. |
offset |
integer | no | Pagination offset. Default: 0. |
search |
string | no | FTS5 full-text query against title and body. |
Returns:
{
"issues": [
{
"id": "01HXYZ...",
"number": 42,
"title": "Add dark mode support",
"status": "triaged",
"assignment": null,
"created_by": "guest:01HXAB...",
"created_at": 1716700000,
"updated_at": 1716700500
}
],
"total": 87,
"limit": 25,
"offset": 0
}
Permission: principal_scope: "root-only". No cage requirements.
Tool: kaged.issue.get
Get full detail of a single issue, including its update stream.
Parameters:
{
"number": 42
}
| Param | Type | Required | Description |
|---|---|---|---|
number |
integer | yes | Project-scoped issue number (e.g., 42 for #42). |
Returns:
{
"id": "01HXYZ...",
"number": 42,
"title": "Add dark mode support",
"body": "The UI should support a dark color scheme...",
"original_body": null,
"status": "triaged",
"assignment": null,
"created_by": "guest:01HXAB...",
"created_at": 1716700000,
"updated_at": 1716700500,
"resolved_at": null,
"resolved_by": null,
"updates": [
{
"id": "01HXYZ...",
"author": "operator",
"kind": "status_change",
"body": null,
"metadata": { "old_status": "open", "new_status": "triaged" },
"visibility": "all",
"created_at": 1716700500
}
]
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Issue not found → error
issue_not_found. - Updates are returned in chronological order.
- Updates with
visibility: "operator_only"are included (the root agent is operator-equivalent for read purposes).
Tool: kaged.issue.create
File a new issue in the current project.
Parameters:
{
"title": "Refactor auth module",
"body": "The auth module has grown to 800 lines. Split into token validation, session management, and OAuth provider modules."
}
| Param | Type | Required | Description |
|---|---|---|---|
title |
string | yes | Issue title. Max 200 characters. |
body |
string | yes | Issue body in Markdown. Max 16 KB. |
Returns:
{
"id": "01HXYZ...",
"number": 43,
"status": "open",
"created_at": 1716701000
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- The
created_byfield is set to"agent:primary". - The issue number is auto-assigned (next sequential per project).
- Title exceeding 200 characters → error
invalid_params. - Body exceeding 16 KB → error
invalid_params.
Tool: kaged.issue.update
Update an issue's title, body, or assignment.
Parameters:
{
"number": 42,
"body": "Rephrased: implement CSS custom property-based theming with prefers-color-scheme media query support.",
"assignment": "primary"
}
| Param | Type | Required | Description |
|---|---|---|---|
number |
integer | yes | Project-scoped issue number. |
title |
string | no | New title. Max 200 characters. |
body |
string | no | New body. Max 16 KB. On first edit, the original body is preserved in original_body. |
assignment |
string | no | Assignment target: null (unassign), "primary", "workflow:<name>", or "session:<sid>". |
Returns:
{
"number": 42,
"updated_fields": ["body", "assignment"],
"updated_at": 1716701500
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- At least one of
title,body,assignmentmust be provided. Otherwise → errorinvalid_params. - Issue not found → error
issue_not_found. - Body edit on an issue that has never been edited preserves the original in
original_body. Subsequent edits do not overwriteoriginal_body. - Assignment to a nonexistent workflow → error
invalid_assignmentwith detail. - Each updated field generates an
issue_updatesrow with the appropriatekind(title_edit,body_edit, orassignment_change).
Tool: kaged.issue.comment
Add a comment to an issue.
Parameters:
{
"number": 42,
"body": "Analysis complete. The auth module can be split into 3 files with no breaking changes to the public API.",
"visibility": "all"
}
| Param | Type | Required | Description |
|---|---|---|---|
number |
integer | yes | Project-scoped issue number. |
body |
string | yes | Comment text in Markdown. Max 16 KB. |
visibility |
enum | no | "all" (visible to everyone) or "operator_only" (internal note). Default: "all". |
Returns:
{
"update_id": "01HXYZ...",
"issue_number": 42,
"created_at": 1716702000
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Issue not found → error
issue_not_found. - The
authorfield on the update row is set to"agent:primary". - Empty body → error
invalid_params.
Tool: kaged.issue.transition
Change an issue's status. Enforces the state machine defined in issues.md.
Parameters:
{
"number": 42,
"to": "assigned",
"comment": "Assigning to primary for implementation."
}
| Param | Type | Required | Description |
|---|---|---|---|
number |
integer | yes | Project-scoped issue number. |
to |
enum | yes | Target status: triaged, assigned, in_progress, resolved, rejected, reopened. |
comment |
string | no | Optional comment attached to the transition. Required when to is rejected. |
Returns:
{
"number": 42,
"from": "triaged",
"to": "assigned",
"updated_at": 1716702500
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Issue not found → error
issue_not_found. - Invalid transition (e.g.,
open→resolved) → errorinvalid_transitionwith detail listing legal transitions from the current status. rejectedwithout a comment → errorinvalid_paramswith message "Rejection requires an explanatory comment."resolvedsetsresolved_atandresolved_byto"agent:primary".- Each transition generates a
status_changeupdate row. If acommentis provided, acommentupdate row is also generated.
Tool: kaged.todo
Manage the working checklist on the session's bound issue. A single root-only tool with action-based dispatch, consistent with the unified kaged.issue shape. The tool operates implicitly on the bound issue — the agent never names the issue.
Per ADR-0034, todos are issue-owned (stored in the issue_todos table) and come in two kinds: step (agent working plan) and criterion (acceptance criteria). The single-in_progress invariant is enforced server-side.
Parameters:
{
"action": "add",
"items": ["Implement auth module", "Write tests", "Update docs"],
"kind": "step"
}
| Param | Type | Required | Description |
|---|---|---|---|
action |
enum | yes | One of: view, set, add, start, done, drop, note. |
content |
string | conditional | Target task, addressed by text. Required for start, done, drop, note. |
items |
string[] | conditional | Task contents for set/add. |
phase |
string | no | Optional work-stage grouping for set/add. |
kind |
enum | no | "step" (default) or "criterion". Used with set/add. |
text |
string | conditional | Note body. Required for note. |
Actions:
| Action | Description |
|---|---|
view |
Return the current todo list (also returned after every mutation). |
set |
Replace the working list. Abandons all existing todos, creates new ones from items. |
add |
Append task(s) to the list. |
start |
Mark a task in_progress. Enforces single-in_progress invariant (auto-demotes any existing in_progress to pending). |
done |
Mark a task completed. For criterion kind, this is a claim, not an autonomous close. |
drop |
Mark a task abandoned (not deleted). Agent cannot drop criterion todos — only the operator can. |
note |
Append a note to a task. |
Returns:
Every action returns the full current todo list, markdown-renderable:
{
"todos": [
{ "id": "01HX...", "content": "Implement auth module", "status": "in_progress", "kind": "step", "phase": null, "position": 0 },
{ "id": "01HX...", "content": "Write tests", "status": "pending", "kind": "step", "phase": null, "position": 1 },
{ "id": "01HX...", "content": "All endpoints return 200", "status": "pending", "kind": "criterion", "phase": null, "position": 2 }
],
"markdown": "- [>] Implement auth module\n- [ ] Write tests\n- [ ] All endpoints return 200 (criterion)"
}
Permission: principal_scope: "root-only". No cage requirements. Requires a bound issue on the session.
Behavior:
- No bound issue → error
no_bound_issuewith guidance to ask the operator to bind an issue. - Task not found by content → error
todo_not_found. Content-addressed lookup is case-insensitive, trimmed. dropon acriteriontodo → errorinvalid_transitionwith message "Only the operator can drop acceptance criteria."startauto-demotes any existingin_progresstodo topendingbefore promoting the target.setabandons all existing todos (status →abandoned) before creating new ones.noteappends to thenotesJSON array on the todo row.doneon acriterionsetscompleted_atbut does not close the issue — closure requires operator checkpoint sign-off.
Todo bubble-up (ADR-0034)
Per ADR-0034 and ADR-0022 rule 10, subagents do not have kaged.todo tool access. The tool carries principal_scope: "root-only". Subagents are storage-blind and domain-blind — they never touch the issue tracker directly.
The todo bubble-up pattern is a sibling of the issue bubble-up pattern documented in issues.md:
- Subagent proposes. A subagent expresses a proposed checklist as structured content in its delegation return value.
- Root reviews. The root agent reviews the proposal: accept as-is, modify, reject, or escalate to the operator (via
kaged.checkpoint/kaged.ask). - Root persists. Only what the root accepts is persisted via
kaged.todo, recorded withorigin_agentset to the subagent's tree-path so the issue shows it as that subagent's sublist. - Subagent sees only its slice. The subagent only ever sees the slice of the issue the root forwards in its delegation message — never the issue itself, never the full todo list.
Review policy is a per-agent knob (auto | review, default review). Whether the root reviews or auto-accepts a subagent's proposal is configurable, not hard-wired.
Tool: kaged.workflow.list
List workflows available in the current project.
Parameters:
{
"invokable_by": "operator"
}
| Param | Type | Required | Description |
|---|---|---|---|
invokable_by |
enum | no | Filter by invoker type: "operator", "guest". Omit for all workflows. |
Returns:
{
"workflows": [
{
"name": "deploy",
"description": "Build and deploy the project to production.",
"inputs": {
"branch": { "type": "string", "required": true, "description": "Git branch to deploy." },
"dry_run": { "type": "boolean", "required": false, "default": false }
},
"invokable_by": ["operator"],
"timeout_seconds": 600
}
],
"count": 1
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Returns workflows from the loaded project DSL. This is a read from the in-memory compiled DSL, not a database query.
- Each workflow's
inputsschema is included so the agent can construct valid invocations.
Tool: kaged.workflow.trigger
Invoke a workflow with structured inputs.
Parameters:
{
"name": "deploy",
"inputs": {
"branch": "main",
"dry_run": true
}
}
| Param | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Workflow name as declared in the project DSL. |
inputs |
object | yes | Input values matching the workflow's inputs schema. |
Returns:
{
"run_id": "01HXYZ...",
"workflow_name": "deploy",
"status": "running",
"created_at": 1716703000
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Workflow not found → error
workflow_not_found. - Input validation failure → error
invalid_paramswith per-field detail (same validation as the HTTP invoke endpoint). - The invoking principal is recorded as
{ type: "operator", id: "agent:primary" }— the root agent acts with operator authority. - The workflow run is created as a session with
kind: "workflow"perworkflows.md. - The tool returns immediately after the run is created. The run executes asynchronously. Use
kaged.workflow.statusto poll for completion. fileinputs are not supported via this tool (file uploads require the HTTP upload protocol). If the workflow has a requiredfileinput, the tool returns errorfile_input_not_supportedwith guidance to use the HTTP endpoint.
Tool: kaged.workflow.status
Get the status of a workflow run.
Parameters:
{
"run_id": "01HXYZ..."
}
| Param | Type | Required | Description |
|---|---|---|---|
run_id |
string | yes | Run ID returned by kaged.workflow.trigger. |
Returns:
{
"run_id": "01HXYZ...",
"workflow_name": "deploy",
"status": "succeeded",
"inputs": { "branch": "main", "dry_run": true },
"created_at": 1716703000,
"completed_at": 1716703120,
"duration_ms": 120000
}
Permission: principal_scope: "root-only". No cage requirements.
Behavior:
- Run not found → error
run_not_found. statusis one of:running,succeeded,failed,cancelled.- For
failedruns, the response includes an additionalerrorfield with the failure reason. - For
runningruns,completed_atandduration_msarenull.
Shell tools
Design philosophy
The shell namespace provides direct command execution via the daemon's PTY broker. Unlike file or search tools that abstract over specific operations, shell tools give agents raw access to the host shell — gated by cage policy and capability requirements. This is intentionally a single tool (shell.bash); additional shells (e.g., shell.python, shell.node) may follow in v0.x but the execution model is the same.
Shell commands run in a PTY (not a raw subprocess), so they inherit the operator's environment and produce output that matches what the operator would see in a terminal. The broker manages process lifecycle including timeout enforcement and graceful kill escalation (SIGTERM → 5s grace → SIGKILL).
Tool: shell.bash
Execute a shell command via /bin/sh -c through the PTY broker.
Parameters:
{
"command": "bun test --recursive packages/",
"cwd": "./packages/daemon",
"timeout": 30000,
"env": { "NODE_ENV": "test" }
}
| Param | Type | Required | Description |
|---|---|---|---|
command |
string | yes | Shell command to execute. Passed to /bin/sh -c. Max 64 KB. |
cwd |
string | no | Working directory, relative to project root. Default: project root. Must resolve within the project tree (no .. escape). |
timeout |
integer | no | Maximum execution time in milliseconds. Default: 120000 (2 minutes). Clamped to range [1000, 600000]. |
env |
object | no | Additional environment variables merged into the process environment. Keys must be non-empty strings; values must be strings. Max 64 entries. |
Returns:
{
"stdout": "bun test v1.2.3\n\n 42 pass\n 0 fail\n",
"stderr": "",
"exit_code": 0,
"timed_out": false
}
| Field | Type | Description |
|---|---|---|
stdout |
string | Combined stdout/stderr captured from the PTY scrollback. The PTY merges streams. |
stderr |
string | Always "" — PTY merges stdout and stderr into a single stream. Present for schema consistency with non-PTY backends. |
exit_code |
integer | Process exit code. 137 typically indicates SIGKILL (timeout). |
timed_out |
boolean | true if the process was killed due to timeout. |
Permission: Requires { kind: "capability", name: "shell" }. Primary agent (no cage) is always allowed. Caged subagents must have shell in their capability set. Additionally requires { kind: "fs", mode: "rw", path: "caller" } — the caller must have read-write filesystem access to the working directory.
Behavior:
- The command is spawned via
PtyBroker.spawn()which usesBun.spawn(["sh", "-c", command]). - Non-zero exit codes are returned as successful outcomes (data), not errors. The agent is expected to interpret exit codes.
- If
timeoutis reached, the broker sends SIGTERM, waits 5 seconds (KILL_GRACE_MS), then sends SIGKILL. The response hastimed_out: true. - Output is captured via the broker's scrollback buffer (
getScrollback()). Output exceeding 1 MB is truncated with a trailing\n[output truncated — 1 MB limit]marker. - Empty command → error
invalid_params. cwdresolving outside the project root → errorinvalid_paramswith detail.envkeys containing=or empty strings → errorinvalid_params.- The process inherits the daemon's environment, with
enventries merged on top (overriding on collision).
Web search tool (web.search)
Design philosophy
The web namespace (reserved by ADR-0033) hosts agent operations against the public web. The first tool in this namespace is web.search — general-purpose web search with multi-provider fallback.
Coding agents that can reach the web are dramatically more useful than agents that cannot: they can look up current API docs, verify error messages against real-world reports, cross-check library versions, and ground their reasoning in primary sources. Unlike search.grep (local content discovery) or search.ast (local code structure), web.search reaches outside the project workspace — and outside the operator's host. It is therefore off by default and opt-in per agent via the standard tools: block. No agent gets web access without the operator explicitly writing "web.search": { enabled: true } in the DSL.
The tool is provider-pluggable. oh-my-pi's WebSearchTool (reference) proved the pattern: a uniform provider interface, a sequential fallback chain, and a markdown formatter that returns numbered sources an LLM can cite. kaged adopts the same shape with one important clarification: search providers are model-independent HTTP APIs. Whether the provider internally synthesises an answer with an LLM (Tavily, Perplexity, Anthropic web search, Gemini grounding) or returns raw results (Brave, Exa, Kagi, Jina, SearXNG) is an implementation detail of the provider. From the daemon's perspective, every provider is a callable that takes a query plus credentials and returns a unified WebSearchResponse. The provider's API key is independent of the operator's conversation LLM.
Why built-in, not MCP-only
ADR-0043 already gives operators MCP as an escape hatch — Brave, Exa, and Tavily all ship MCP servers, so an operator could wire up mcp.brave.web_search via the existing DSL surface without any new code in kaged. The case for a first-party web.search built-in alongside that escape hatch rests on three points:
- Fallback chains we own. MCP tool dispatch is one-shot — call the server, get a result, return it. There is no MCP-native notion of "try Brave, fall back to Exa on rate-limit, fall back to Tavily on auth failure." Operators want this behaviour. Building it into a kaged-owned built-in gives every operator the same fallback story without each one having to author it themselves.
- Provider-agnostic response shape. Each MCP search server returns its own idiosyncratic JSON. A first-party built-in normalises the response into a single
WebSearchResponse(sources, optional synthesised answer, optional related queries) and formats it as numbered markdown for the LLM. The agent's prompt does not have to know which provider produced the result. - Operator-side config, project-side enablement. Provider credentials are per-operator (ADR-0011 — secrets never travel with the project). A first-party built-in lets the operator configure provider order and API keys once in
local.tomland have every kaged project reuse them. MCP servers, by contrast, are declared per-project in the DSL.
MCP remains the right answer for operators who want a single, specific search provider and don't need a fallback chain. The two coexist.
Provider abstraction
Every search backend implements the same interface. The daemon's web-handlers.ts is provider-agnostic; it only knows the abstraction.
/** Uniform parameters passed to every provider. */
interface WebSearchParams {
query: string;
recency?: "day" | "week" | "month" | "year";
limit?: number; // preferred max results (provider may cap)
maxOutputTokens?: number; // hint for LLM-backed providers
numSearchResults?: number; // hint for LLM-backed providers
temperature?: number; // hint for LLM-backed providers
signal?: AbortSignal; // composed with the daemon's hard timeout
}
/** Uniform response returned by every provider. */
interface WebSearchResponse {
provider: string; // the provider id that produced this response
answer?: string; // synthesised answer (LLM-backed providers)
sources: WebSearchSource[]; // numbered citations
citations?: WebSearchCitation[]; // optional deeper citations (provider-specific)
relatedQuestions?: string[];
searchQueries?: string[]; // queries the provider actually issued
}
interface WebSearchSource {
title: string;
url: string;
snippet?: string;
publishedDate?: string; // ISO-8601 if known
ageSeconds?: number; // convenience for formatter
}
interface WebSearchCitation {
title?: string;
url: string;
citedText?: string;
}
/** Every search backend implements this. */
abstract class WebSearchProvider {
abstract readonly id: string; // "brave", "exa", "anthropic", ...
abstract readonly label: string; // human-readable
abstract readonly needsApiKey: boolean;
/** Returns true if the credentials/config for this provider are present and the provider is not disabled. */
abstract isAvailable(config: WebProviderConfig): boolean;
/** Execute a search. Throw on any failure — the fallback chain handles it. */
abstract search(params: WebSearchParams, config: WebProviderConfig): Promise<WebSearchResponse>;
}
Provider implementations live in packages/daemon/src/runtime/web/providers/. Each is a single file (~150 LOC) that translates WebSearchParams into the provider's HTTP/LLM API and the provider's response into WebSearchResponse. Providers never see each other; they only see the abstraction.
Provider catalog
The catalog is divided into two tiers based on what the provider returns, not what it uses internally. Both tiers implement the same WebSearchProvider interface.
Tier 1 — Direct search APIs
Return raw search results (titles, URLs, snippets). No synthesised answer. The daemon's formatter composes the final markdown.
| Provider id | API | Auth | Notes |
|---|---|---|---|
brave |
GET https://api.search.brave.com/res/v1/web/search |
X-Subscription-Token header |
Broad coverage, freshness filter via freshness param (day=pd, week=pw, month=pm, year=py). |
exa |
POST https://api.exa.ai/search |
x-api-key header |
Semantic / neural search. Optional contents: { summary: { query } } to ask for AI summaries per result. |
kagi |
GET https://kagi.com/api/v1/search |
Authorization header | Paid, ad-free. Operator must have a Kagi account. |
jina |
GET https://s.jina.ai/<encoded-query> |
Authorization header | Returns markdown-rendered page content; good for "give me the page" use cases. |
searxng |
GET <operator-configured-endpoint>/search |
Basic auth or bearer | Self-hosted. The endpoint URL is operator-configured via [web.searxng] in local.toml. |
Tier 2 — LLM-backed search providers
Make their own LLM call internally to synthesise an answer with citations. The API key authenticates both the search and the LLM call. The operator's conversation LLM and the search LLM are independent — using Anthropic's web search tool does not require the operator to use Anthropic as their conversation model.
| Provider id | API | Auth | Notes |
|---|---|---|---|
tavily |
POST https://api.tavily.com/search |
Bearer token |
Purpose-built for agents. Returns answer field synthesised from results. search_depth: "advanced", include_answer: "advanced". |
perplexity |
POST https://api.perplexity.ai/chat/completions |
Bearer token |
LLM-native. Uses sonar models with built-in web access. Returns answer + citations in response. |
anthropic |
POST https://api.anthropic.com/v1/messages |
x-api-key header |
Enables the web_search_20250305 server tool; Claude issues the search and synthesises the answer. |
gemini |
POST https://generativelanguage.googleapis.com/... |
x-goog-api-key header / query |
Uses Google Search grounding via tools: [{ googleSearch: {} }] or googleSearchRetrieval. |
openai |
POST https://api.openai.com/v1/responses |
Bearer token |
Uses the OpenAI Responses API with the web_search built-in tool. |
zai |
POST https://api.z.ai/api/paas/v4/chat/completions |
Bearer token |
Z.AI / Antigravity with built-in web search via tools: [{ type: "web_search" }]. |
kimi |
POST https://api.moonshot.cn/v1/chat/completions |
Bearer token |
Moonshot Kimi with built-in web search. |
synthetic |
POST https://api.synthetic.new/v2/search |
Bearer token |
Synthetic.new search API. |
The daemon implementation MAY ship with a subset of these in the initial phase. The provider interface is stable; additional providers land as drop-in files under packages/daemon/src/runtime/web/providers/ with no other code changes required.
Fallback chain
The daemon does not pick a single provider. It tries providers in order until one succeeds.
Chain order resolution (highest priority first):
web.preferred_providerinlocal.toml [web]— if set and available, that provider goes first.web.provider_orderinlocal.toml [web]— explicit ordered list. Only listed providers are tried, in the given order.- Default order: direct-API providers first (lower latency, no token cost), LLM-backed providers second:
["brave", "exa", "tavily", "kagi", "jina", "searxng", "perplexity", "anthropic", "gemini", "openai", "zai", "kimi", "synthetic"]
Availability check: before each provider is tried, isAvailable(config) is called. A provider is available when its API key is present in [secrets.web.<provider>] (or the operator-configured endpoint, for searxng) AND it is not explicitly disabled in [web.<provider>]. Unavailable providers are silently skipped — they do not count as fallback failures.
Failure semantics:
- Any thrown error from
provider.search()(network, HTTP 4xx/5xx, timeout, quota exhausted, parse failure) triggers fallback to the next available provider. - The failure is recorded in
fallback_attemptswith the provider id and a short error code (rate_limited,auth_failed,network_error,timeout,quota_exhausted,provider_error). - AbortSignal cancellation does NOT trigger fallback. If the operator (via run cancellation) or the hard timeout aborts the call, the entire tool call returns immediately with
aborted. - If every available provider fails, the tool returns error
web_search_all_providers_failedwith the fullfallback_attemptschain in the detail field.
Hard timeout: every provider call is wrapped in AbortSignal.any([signal, AbortSignal.timeout(60_000)]). The 60s default is configurable via [web] hard_timeout_ms in local.toml. Per-provider timeout overrides live in [web.<provider>] timeout_ms.
Response formatting
The daemon formats WebSearchResponse into a single markdown string the LLM consumes as the tool result. The format is fixed so prompt engineering on the model side can rely on it:
<answer if present>
## Sources (N)
[1] Title (age or publish-date)
https://example.com/source-1
Optional snippet, truncated to 240 chars.
[2] ...
## Citations (M) ← only if response.citations present
[1] Title
https://example.com/citation-1
Optional cited text, truncated to 240 chars.
## Related ← only if response.relatedQuestions present
- Related question one?
- Related question two?
Search queries (K): ← only if response.searchQueries present
- actual query the provider issued
The tool's content return field carries this string verbatim. The structured sources_count, provider_used, truncated, and fallback_attempts fields ride alongside for UI / observability.
Rate limiting, retries, and caching
- No retries within a single provider. A failed provider is recorded and the chain advances. Operators who want retries can configure them at the
[web.<provider>]level in a future amendment; MVP does not retry. - No result caching. Search results are time-sensitive; a cached result is often a wrong result. Each call hits the provider.
- Hard timeout is the only time bound. 60s default, configurable. Cancellation via
AbortSignalpropagates immediately. - Provider-side rate limits are surfaced as
rate_limitedinfallback_attempts. The daemon does not attempt to wait and retry — the next provider is tried.
Local-config integration
Provider credentials and operator preferences live in local.toml. Per ADR-0011, no provider configuration of any kind appears in the project DSL — the DSL only enables web.search per agent via the standard tools: block. Everything else is operator-local.
# local.toml
[web]
# Operator-level preferences. None are required.
preferred_provider = "brave" # try this provider first
# provider_order = ["brave", "exa", "tavily"] # explicit ordered list (overrides preferred_provider + default order)
hard_timeout_ms = 60000 # per-provider hard timeout, default 60000
[web.searxng]
endpoint = "https://searxng.operator.local" # required for searxng provider
[web.brave]
timeout_ms = 15000 # per-provider timeout override
# API keys — flat map under [secrets.web]. Key = provider id, value = API key.
# All MVP providers use a single API key; future providers needing multiple
# credentials will require a schema extension.
[secrets.web]
brave = "BSA-xxxxxxxxxxxxxxxx"
exa = "exa-xxxxxxxxxxxxxxxx"
tavily = "tvly-xxxxxxxxxxxxxxxx"
anthropic = "sk-ant-xxxxxxxxxxxxxxxx"
gemini = "AIzaXXXXXXXXXXXXXXXX"
# ...one key per provider the operator has credentials for.
Secret resolution: the daemon looks up a provider's API key in the flat [secrets.web] map — key is the provider id, value is the API key. This fits the existing 2-level secrets schema (Record<string, Record<string, string>>). Missing key for a provider → provider marked unavailable (not a startup error). All MVP providers use a single API key; providers needing multiple credentials in the future will require a schema extension.
Redaction: redactSecrets() strips all [secrets.web.*] values before any config is surfaced to the UI, API, or logs. This extends the existing redactor that already handles [secrets.*] and provider API keys.
See local-config.md § [web] for the normative schema.
Tool: web.search
A single unified tool. Off by default; agents opt in via their tools: block.
Parameters:
{
"query": "Bun.serve options.interface documentation",
"recency": "month",
"limit": 5
}
| Param | Type | Required | Description |
|---|---|---|---|
query |
string | yes | Search query. Max 1024 characters. |
recency |
enum | no | day, week, month, year. Filters results to within the period. Provider may ignore if unsupported. |
limit |
integer | no | Preferred max sources in the response. Default: 5. Clamped to [1, 20]. Provider may cap below this. |
max_tokens |
integer | no | Hint for LLM-backed providers (Tavily, Perplexity, Anthropic, etc.) on max output tokens for the synthesised answer. Ignored by direct-API providers. |
temperature |
number | no | Hint for LLM-backed providers. Clamped to [0, 2]. |
num_search_results |
integer | no | Hint for LLM-backed providers on how many underlying searches to run. |
Returns:
{
"content": "<markdown-formatted response, see § Response formatting>",
"provider_used": "brave",
"sources_count": 5,
"truncated": false,
"fallback_attempts": [
{ "provider": "brave", "error": "rate_limited", "message": "HTTP 429" }
]
}
| Field | Type | Description |
|---|---|---|
content |
string | Markdown-formatted response — synthesised answer (if any), numbered sources, optional citations, optional related questions. This is what the LLM reads. |
provider_used |
string | The provider id that produced the successful response. |
sources_count |
integer | Number of sources in the response. |
truncated |
boolean | true if any source snippet or the synthesised answer was truncated to fit the formatter's per-field limits (240 chars for snippets/citations). |
fallback_attempts |
array | Empty if the first provider succeeded. Otherwise, one entry per provider tried before the successful one, with provider, error (short code), and message. |
Permission: none. web.search has no requires entries. It is a daemon-brokered outbound HTTP call, not a cage-mediated resource. Authorisation is the operator's act of enabling the tool on an agent and configuring credentials in local.toml.
principal_scope: not set. Any agent (primary or subagent) can be granted web.search via its tools: block.
Behavior:
- The daemon resolves the provider chain from
local.toml [web]at handler construction time (cheaper than per-call resolution; the handler holds the resolved chain). - For each call, the chain is walked in order. The first available provider that returns successfully wins.
- Each provider call is wrapped in
AbortSignal.any([incoming-signal, hard-timeout]). The incoming signal is the run'sAbortController— cancellation aborts the entire tool call, no fallback. - The response is formatted via the daemon's
formatForLLM(response)helper, which produces the fixed markdown shape documented above. - If
queryis empty or whitespace-only → errorinvalid_params. - If
queryexceeds 1024 chars → errorinvalid_params. - If no providers are available (no credentials configured) → error
web_search_no_providers_configured. This is a configuration error, not a transient failure. - If all available providers fail → error
web_search_all_providers_failedwith the fullfallback_attemptschain indetail.
Worked example — happy path (single provider):
// Agent calls:
{ "query": "bun spawn typescript example", "recency": "year", "limit": 3 }
// Tool returns:
{
"content": "## Sources (3)\n[1] Bun Documentation — Bun.spawn (2 months ago)\n https://bun.com/docs/api/spawn\n Bun.spawn spawns a child process ...\n[2] ...",
"provider_used": "brave",
"sources_count": 3,
"truncated": false,
"fallback_attempts": []
}
Worked example — fallback path:
// Agent calls:
{ "query": "postgres jsonb path query examples" }
// Brave is rate-limited; Exa succeeds. Tool returns:
{
"content": "...",
"provider_used": "exa",
"sources_count": 5,
"truncated": false,
"fallback_attempts": [
{ "provider": "brave", "error": "rate_limited", "message": "HTTP 429: rate limit exceeded" }
]
}
Worked example — LLM-backed provider:
// Agent calls:
{ "query": "explain rust async drop semantics", "max_tokens": 1024 }
// brave + exa configured but operator marked tavily as preferred_provider.
// Tavily synthesises an answer from its search results. Tool returns:
{
"content": "Rust does not have async Drop ...\n\n## Sources (4)\n[1] ...\n\n## Related\n- How does async drop interact with tokio?",
"provider_used": "tavily",
"sources_count": 4,
"truncated": false,
"fallback_attempts": []
}
Per-agent enablement
web.search is off by default for every agent, including the primary. The operator opts in per agent:
# .kaged/project.yaml
primary:
tools:
"web.search": { enabled: true }
subagents:
researcher:
tools:
"web.search": { enabled: true } # subagent can search too
The standard per-agent resolution rules apply (see § Per-agent tool resolution). There is no project-level web: block in the DSL — provider configuration is purely operator-local.
Sandbox integration
Permission enforcement
Every tool call passes through ToolPermissions before execution. The permission check is based on the caller's cage_policy:
// Conceptual
function checkPermission(
tool: ToolDefinition,
params: Record<string, unknown>,
context: ToolCallContext
): "allowed" | PermissionError {
// Primary agent has no cage — always allowed
if (context.cage_policy === null) return "allowed";
const policy = context.cage_policy;
for (const req of tool.requires) {
switch (req.kind) {
case "fs": {
const requestedPath = resolve(context.project_root, params.path);
const mode = req.mode;
const allowed = policy.fs.some(mount =>
requestedPath.startsWith(resolve(context.project_root, mount.path)) &&
(mode === "ro" || mount.mode === "rw")
);
if (!allowed) return { code: "capability_denied", detail: `${mode}:fs:${params.path}` };
break;
}
case "seccomp": {
if (policy.seccomp !== req.profile) {
return { code: "seccomp_insufficient", detail: `requires ${req.profile}, have ${policy.seccomp}` };
}
break;
}
}
}
return "allowed";
}
Result filtering
For read-only tools that return results across the workspace (e.g., search.grep, code.lsp references action), the bridge returns all results but marks those outside the caller's cage:
- Inside cage: full result with content previews.
- Outside cage: path and line number only. No content preview. Marked
"outside_cage": true.
This gives agents workspace-wide awareness (they can see that a function is used in 15 files) without leaking file contents they shouldn't read.
Tool availability by cage type
| Tool | cage: disabled / primary |
default seccomp |
relaxed seccomp |
|---|---|---|---|
file.read |
all files | cage fs entries (ro or rw) |
cage fs entries (ro or rw) |
file.write / edit.text |
all files | cage fs entries (rw only) |
cage fs entries (rw only) |
search.grep / search.glob |
project root | cage fs entries (ro or rw) |
cage fs entries (ro or rw) |
search.ast |
project root | cage fs entries (ro or rw) |
cage fs entries (ro or rw) |
code.lsp (read actions) |
all | all (results filtered) | all (results filtered) |
code.lsp (rename action) |
all files | cage fs entries (rw) for all affected files |
cage fs entries (rw) for all affected files |
debug (launch — WebSocket debugger, e.g. Bun) |
yes | yes | yes |
debug (launch — ptrace-based debugger) |
yes | no — error with guidance | yes |
debug (attach) |
yes | depends on debugger type | yes |
shell.bash |
yes (capability: shell) |
cage fs entries (rw) + shell capability |
cage fs entries (rw) + shell capability |
kaged.issue.* |
root agent only | no — principal_scope rejection |
no — principal_scope rejection |
kaged.todo |
root agent only | no — principal_scope rejection |
no — principal_scope rejection |
kaged.workflow.* |
root agent only | no — principal_scope rejection |
no — principal_scope rejection |
Failure modes
| Failure | Detection | Recovery | Agent impact |
|---|---|---|---|
| Rust FFI library not found | Daemon startup | Daemon refuses to start | None — fatal startup gate |
| Language server binary not found | First code.lsp call for that language |
Error returned to agent | Agent sees lsp_server_not_found with install instructions. Can continue without LSP. |
| Language server crashes | EOF on stdio | Bridge restarts server (up to 3 times, then gives up for this session) | Agent sees lsp_server_unavailable on next call. Retry after bridge restart. |
| Language server hangs | Request timeout (30s) | Bridge kills server, restarts | Agent sees timeout error. Next call triggers restart. |
| Debug adapter crashes | EOF on stdio or DAP terminated event |
Debug session marked ended | Agent sees dap_session_ended. Must call debug with action: "launch" again. |
| Debugged process exits unexpectedly | DAP exited event |
Debug session transitions to exited state |
Agent sees status: "exited" on next step/continue. |
| File edit conflict (file changed between read and edit) | Content hash mismatch | Edit rejected | Agent sees file_changed_since_read. Must re-read and retry. |
| Permission denied (cage) | ToolPermissions check |
Tool call rejected | Agent sees capability_denied with detail on what's needed. |
search.grep timeout (60s) |
Watchdog | Results returned as partial with truncated: true |
Agent gets partial results. Can narrow the search. |
LSP rename affects files outside cage |
Pre-check of edit scope | Rename rejected before any changes | Agent sees rename_scope_exceeds_cage with list of inaccessible files. |
| AST pattern invalid | ast-grep parse error | Error returned | Agent sees invalid_pattern with syntax guidance. |
| Concurrent tool calls to same file | In-memory file lock | Second call waits (up to 5s, then error) | Agent sees file_busy if timeout exceeded. |
principal_scope violation (non-root calls kaged.*) |
Registry dispatch check | Tool call rejected | Agent sees principal_scope_violation. DSL parser should have caught this at load time; runtime check is defense-in-depth. |
| Issue not found | Database lookup | Error returned | Agent sees issue_not_found with the requested number. |
| Invalid issue transition | State machine check | Error returned | Agent sees invalid_transition with legal transitions from current status. |
No bound issue for kaged.todo |
Session lookup | Error returned | Agent sees no_bound_issue with guidance to ask operator to bind an issue. |
| Todo not found by content | Content-addressed lookup | Error returned | Agent sees todo_not_found with the searched content. |
| Agent drops criterion todo | Kind check in handler | Error returned | Agent sees invalid_transition — only operator can drop criteria. |
| Workflow not found | DSL lookup | Error returned | Agent sees workflow_not_found with the requested name. |
| Workflow input validation failure | Zod schema check | Error returned | Agent sees invalid_params with per-field detail. |
Workflow file input via tool |
Input type check | Error returned | Agent sees file_input_not_supported — file uploads require HTTP endpoint. |
| Workflow run not found | Database lookup | Error returned | Agent sees run_not_found with the requested run ID. |
| Shell command empty | Parameter validation | Error returned | Agent sees invalid_params — command is required. |
Shell cwd escapes project root |
Path resolution check | Error returned | Agent sees invalid_params with detail on the resolved path. |
| Shell command timeout | PTY broker watchdog | Process killed (SIGTERM → SIGKILL) | Agent sees result with timed_out: true and exit_code: 137. |
| Shell output exceeds 1 MB | Scrollback size check | Output truncated | Agent sees truncated output with [output truncated — 1 MB limit] marker. |
| Shell spawn failure | Bun.spawn error |
Error returned | Agent sees shell_spawn_failed with system error detail. |
Audit events
| Event | When | Data |
|---|---|---|
tool.called |
Every tool invocation | tool_name, caller, session_id, run_id, request_id, params (redacted for large content) |
tool.completed |
Tool returns result | tool_name, request_id, duration_ms, success |
tool.denied |
Permission check failed | tool_name, caller, denied_capability, cage_summary |
code.lsp.server_spawned |
Language server started | language, command, project_id |
code.lsp.server_crashed |
Language server died unexpectedly | language, exit_code, restart_count |
code.lsp.server_stopped |
Language server shut down cleanly | language, reason (idle_timeout, project_unload, daemon_shutdown) |
debug.session_started |
Debug session created | debug_session_id, runtime, script, caller |
debug.session_ended |
Debug session terminated | debug_session_id, reason (disconnect, crash, run_ended) |
debug.breakpoint_hit |
Execution paused at breakpoint | debug_session_id, breakpoint_id, path, line |
file.written |
File created or overwritten | path, bytes, caller |
file.edited |
File edited | path, replacements, caller |
kaged.issue.created |
Root agent files an issue | issue_id, project_id, number, title, caller |
kaged.issue.updated |
Root agent updates an issue | issue_id, updated_fields, caller |
kaged.issue.commented |
Root agent comments on an issue | issue_id, update_id, visibility, caller |
kaged.issue.transitioned |
Root agent changes issue status | issue_id, from_status, to_status, caller |
kaged.todo.set |
Root agent replaces todo list | issue_id, count, kind, caller |
kaged.todo.added |
Root agent adds todos | issue_id, count, kind, caller |
kaged.todo.started |
Root agent starts a todo | issue_id, todo_id, content, caller |
kaged.todo.completed |
Root agent completes a todo | issue_id, todo_id, content, kind, caller |
kaged.todo.dropped |
Root agent abandons a todo | issue_id, todo_id, content, caller |
kaged.todo.noted |
Root agent appends a note | issue_id, todo_id, caller |
kaged.workflow.triggered |
Root agent triggers a workflow | run_id, workflow_name, inputs (redacted for large values), caller |
kaged.workflow.status_checked |
Root agent polls workflow run status | run_id, workflow_name, status, caller |
shell.executed |
Shell command executed | command (truncated to 200 chars), cwd, exit_code, timed_out, duration_ms, caller |
Testing notes
File tool tests
file.readhappy path: read a known file, assert line-numbered content.file.readbinary detection: read a.png, asserttype: "binary".file.readoffset/limit: read lines 10-20 of a 100-line file.file.writecreates parent dirs: write to./a/b/c/new.ts, assert directories created.file.writerequires prior read: write without reading first, assertfile_not_read.edit.textexact match: edit a known string, assert replacement.edit.textmultiple matches:old_stringappears 3 times,replace_allfalse, assertmultiple_matches.edit.textno match:old_stringnot in file, assertold_string_not_found.file.createexisting file: file exists, assertfile_exists.
Search tool tests
search.grepregex match: search forTODO, assert matching lines.search.grepfile filter: search*.tsonly, assert no.jsresults.search.greptimeout: search a huge directory with pathological regex, assert timeout with partial results.search.globpattern:**/*.test.ts, assert only test files.search.astmeta-variable: patternconsole.log($MSG), assert matches with captured$MSG.edit.astdry-run preview: patternconsole.log($MSG)→logger.debug($MSG),dry_run: true, assertapplied: falseand changes array populated.edit.astapply: same pattern withdry_run: false, assertapplied: trueand file on disk modified.edit.astpath resolution: single file path, assert only that file touched; directory path, assert all matching files.edit.astinvalid pattern: unparseable ast-grep pattern, assertinvalid_pattern.edit.astlanguage inference: mixed.tsand.jsfiles without explicitlang, assert inference error or successful inference when uniform.edit.astmax_replacements cap: setmax_replacements: 1with 3 matches, assertlimit_reached: trueand only 1 replacement.edit.astmax_files cap: setmax_files: 1with 2 matching files, assertlimit_reached: true.edit.astpath traversal rejection: passpath: "../../etc", assertinvalid_params.edit.astparse error collection: file with syntax errors among candidates, assertparse_errorspopulated and operation continues on parseable files.
code.lsp tool tests
- Server spawn on demand: first
code.lspdiagnosticscall spawns the server, assert server process exists. diagnosticsreturns errors: file with a type error, assert diagnostic with correct line/message.definitionnavigates: cursor on a function call, assert definition in the correct file.referencesfinds usages: cursor on a type, assert all usage locations.renameatomic: rename a symbol used in 3 files. Assert all 3 updated. Introduce a write failure on file 2 — assert all 3 rolled back.renamecage scoping: rename a symbol used in files inside and outside the cage. Assert rejection before any edits.- File sync: agent edits a file via
edit.text, then callscode.lspdiagnostics. Assert the diagnostics reflect the edit (not stale). - Server crash recovery: kill the language server mid-request. Assert the bridge restarts it and the next call succeeds.
- Idle timeout: call
code.lspdiagnostics, wait 10+ minutes, call again. Assert server was stopped and re-spawned.
debug tool tests
launch+ breakpoint + continue: launch a script, set a breakpoint, continue, assert stopped at breakpoint.variablesinspection: at a breakpoint, inspect local variables. Assert correct names, values, types.evaluatewatch: evaluate an expression at a breakpoint. Assert result.step_into/step_over/step_out: at a breakpoint, step over a function call, assert cursor moves to next line. Step into, assert cursor inside the function.disconnectcleanup: disconnect, assert debug adapter process is gone and debuggee is killed.- Auto-cleanup on run end: cancel a run while a debug session is active. Assert debug session is cleaned up.
defaultseccomp rejection: subagent indefaultcage callsdebugwithaction: "launch"with ptrace-based debugger. Assertdap_requires_relaxed_seccomp.- Bun
--inspectworks underdefaultseccomp: Bun's WebSocket debugger doesn't need ptrace. Assertdebuglaunchsucceeds.
Permission tests
- Cage read enforcement: subagent with
fs: [{mode: ro, path: ./src}]reads./src/main.ts(ok), reads./data/secrets.json(denied). - Cage write enforcement: subagent with
fs: [{mode: ro, path: ./src}]triesedit.texton./src/main.ts(denied — ro, not rw). - Primary has no cage: primary calls any tool on any path — all succeed.
- Search result filtering: subagent with restricted cage calls
search.grepon project root. Results outside cage have no content preview.
Project management tool tests
kaged.issue.listhappy path: project has 3 issues, assert all returned with correct fields.kaged.issue.liststatus filter: filter bytriaged, assert only triaged issues returned.kaged.issue.listsearch: FTS5 query matches title and body content.kaged.issue.listpagination: 30 issues,limit: 10, offset: 10, assert correct slice.kaged.issue.gethappy path: get issue #42, assert full detail including updates.kaged.issue.getnot found: get nonexistent issue, assertissue_not_found.kaged.issue.getincludes operator-only updates: assertvisibility: "operator_only"updates are included for root agent.kaged.issue.createhappy path: create issue, assert sequential number assigned and status isopen.kaged.issue.createtitle too long: 201 characters, assertinvalid_params.kaged.issue.createauthor identity: assertcreated_byis"agent:primary".kaged.issue.updatebody edit: update body, assertoriginal_bodypreserved on first edit.kaged.issue.updatesecond body edit: update body again, assertoriginal_bodyunchanged from first edit.kaged.issue.updateassignment: assign to"workflow:deploy", assert assignment recorded.kaged.issue.updateinvalid assignment: assign to nonexistent workflow, assertinvalid_assignment.kaged.issue.commenthappy path: add comment, assert update row created withkind: "comment".kaged.issue.commentoperator-only: add withvisibility: "operator_only", assert visibility stored.kaged.issue.transitionhappy path:open→triaged, assert status changed and update row created.kaged.issue.transitioninvalid:open→resolved, assertinvalid_transitionwith legal transitions.kaged.issue.transitionrejected requires comment: transition torejectedwithout comment, assertinvalid_params.kaged.issue.transitionresolved sets timestamps: transition toresolved, assertresolved_atandresolved_byset.
kaged.todo tool tests
viewhappy path: session has bound issue with 3 todos, assert all returned with correct fields and markdown.viewno bound issue: session has no bound issue, assertno_bound_issueerror.setreplaces list: set 3 items, assert existing todos abandoned and new ones created.setwith kind: set items withkind: "criterion", assert kind persisted.setwith phase: set items withphase: "discovery", assert phase persisted.addappends: add 2 items to existing list, assert position continues from max.startpromotes: start a pending todo, assert statusin_progress.startsingle-in_progress: start a second todo while one isin_progress, assert first demoted topending.donecompletes: mark a todo done, assertcompleted_atset.donecriterion is a claim: mark a criterion done, assertcompleted_atset but issue not resolved.dropabandons: drop a step todo, assert statusabandoned.dropcriterion rejected: drop a criterion todo, assertinvalid_transitionerror.noteappends: add a note, assert notes array grows.notemax length: note exceeds 2000 chars, assertinvalid_params.- Content-addressed lookup: start a todo by partial text match, assert correct todo found.
- Content not found: start a todo with nonexistent content, assert
todo_not_found.
kaged.workflow.list happy path:** project has 2 workflows, assert both returned with input schemas.
kaged.workflow.listfilter: filter byinvokable_by: "guest", assert only guest-invokable workflows returned.kaged.workflow.triggerhappy path: triggerdeploywith valid inputs, assertrun_idreturned and status isrunning.kaged.workflow.triggernot found: trigger nonexistent workflow, assertworkflow_not_found.kaged.workflow.triggerinvalid inputs: missing required input, assertinvalid_paramswith per-field detail.kaged.workflow.triggerfile input: workflow has requiredfileinput, assertfile_input_not_supported.kaged.workflow.triggerinvoker identity: assert invoking principal recorded as{ type: "operator", id: "agent:primary" }.kaged.workflow.statushappy path: poll completed run, assertsucceededwith duration.kaged.workflow.statusrunning: poll in-progress run, assertcompleted_atandduration_msare null.kaged.workflow.statusfailed: poll failed run, asserterrorfield present.kaged.workflow.statusnot found: poll nonexistent run, assertrun_not_found.
Principal scope enforcement tests
- Root agent calls
kaged.issue.list: assert success. - Non-root agent calls
kaged.issue.list: assertprincipal_scope_violationat dispatch time. - DSL with
kaged.issue.createon subagent: assert parse-time rejection (schema error). - DSL with
kaged.workflow.triggeron subagent: assert parse-time rejection. - Per-agent tool resolution: root agent with no
tools:override, assertkaged.issue.*andkaged.workflow.*enabled by default. - Per-agent tool resolution: subagent with no
tools:override, assert empty tool set (no defaults). - Per-agent tool resolution: subagent with explicit
"file.*": { enabled: true }, assert only file tools enabled. - Root agent suppresses default: root agent with
"kaged.issue.*": { enabled: false }, assert issue tools disabled.
Shell tool tests
shell.bashhappy path: executeecho hello, assertstdoutcontainshello,exit_codeis0,timed_outisfalse.shell.bashnon-zero exit: executeexit 42, assertexit_codeis42, result is success (not error).shell.bashcustom cwd: executepwdwithcwd: "./packages/daemon", assert output containspackages/daemon.shell.bashcwd escape: execute withcwd: "../../", assertinvalid_paramserror.shell.bashtimeout: executesleep 60withtimeout: 2000, asserttimed_outistrueandexit_codeis137.shell.bashtimeout clamping: passtimeout: 500, assert clamped to1000.shell.bashtimeout clamping upper: passtimeout: 999999, assert clamped to600000.shell.bashempty command: passcommand: "", assertinvalid_params.shell.bashenv vars: executeecho $FOOwithenv: { "FOO": "bar" }, assert output containsbar.shell.bashenv validation: passenv: { "": "val" }, assertinvalid_params.shell.bashoutput truncation: generate >1 MB output, assert truncated with marker.
Open questions
Structural rewrite tool (Resolved: exposed assearch.ast_replace).edit.ast(dry-run by default, requires explicitdry_run: falseto apply). Tool definition and handler wired inpackages/daemon/src/runtime/tool-handlers/edit-handlers.ts, using the existingastEditRust N-API export. Tests cover dry-run, path resolution, and rewrite application.LSP multi-root workspaces. Some language servers support multi-root workspaces. kaged projects are single-root by design. If a subagent needs LSP for a dependency (e.g.,
node_modules), the current model handles it (the server sees the whole project tree). But monorepo projects with multiple kaged projects may want a shared server. Deferred.DAP for compiled languages. Debugging Go, Rust, or C++ requires
delve,lldb, orgdb— which have their own DAP adapters. v0 supports interpreted runtimes (Bun, Node, Python). Compiled-language DAP adapters are v0.x.Tool versioning. Built-in tools don't version today (they're part of the daemon). If we change a tool's parameter schema, agents with cached tool definitions may break. A
tool_versionfield in the registry is plausible. Not v0.Concurrent LSP requests. Language servers vary in their concurrency support. Some handle concurrent requests; some serialize. The bridge should respect the server's capabilities (from the
initializeresponse). v0: serialize all requests to a given server. v0.x: parallel where the server supports it.DAP logpoints as a linter-adjacent workflow. Logpoints (
debugwithaction: "set_breakpoint"andlog_message) are essentially structuredconsole.logwithout modifying source. Should the tooling layer encourage agents to use logpoints before full breakpoint debugging? Possibly — but this is an agent-prompting concern, not a tool-layer concern.File watching. Should the daemon watch the project filesystem for external changes (operator editing files in their IDE) and notify agents / update LSP? v0: no. The daemon trusts its own tool calls as the source of truth. External changes are invisible until the agent re-reads the file. v0.x may add inotify-based sync.
Tool usage quotas. Should there be per-run or per-session limits on tool calls (e.g., max 1000 file reads per run)? v0: no hard limits. The walltime limit on cages is the indirect bound. If agents abuse tools, the operator adjusts the walltime or intervenes at a checkpoint.
Skill tool (skill.use)
The skill namespace (reserved by ADR-0033) hosts the on-demand skill activation tool. Per ADR-0044, skills are declarative agent capabilities — structured markdown modules (SKILL.md with YAML frontmatter) that inject domain knowledge, methodology, or instructions into the agent's context.
This tool is dynamically registered. Unlike every other built-in tool, skill.use is not in the global builtin tool set. It is created per-agent at session start only when the agent has discovered skills (from .kaged/skills/*/SKILL.md matched against the agent's skills: glob filter). If an agent has no skills, the tool is not registered and the agent has no skill.* tool at all.
The catalog lives in the tool description. The tool's description field contains a human/LLM-readable catalog of available skills (name + description for each). This eliminates the need for a separate system-prompt injection block — the LLM sees tool definitions, so the catalog travels with the tool.
Tool: skill.use
{
"name": "skill.use",
"namespace": "skill",
"description": "Activate a skill — load domain knowledge, methodology, or instructions into your context.\n\nAvailable skills:\n\n- **code-review**: Systematic code review methodology — check for correctness, security, performance, and style.\n- **deploy-runbook**: Step-by-step deployment procedure for this project.",
"source": "builtin",
"parameters": {
"type": "object",
"required": ["skill"],
"properties": {
"skill": {
"type": "string",
"enum": ["code-review", "deploy-runbook"],
"description": "The skill to activate."
}
}
},
"returns": {
"type": "object",
"properties": {
"content": { "type": "string" }
}
},
"requires": [],
"principal_scope": undefined
}
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
skill |
string (enum) | yes | The skill name to activate. Enum values are the discovered skill directory names. |
Returns: Text content containing the skill body wrapped in a <skill_content> element with a path hint and <skill_resources> listing:
<skill_content name="pdf-processing">
# PDF Processing
## When to use this skill
Use this skill when the user needs to work with PDF files...
[rest of SKILL.md body — frontmatter stripped]
Skill directory: .kaged/skills/pdf-processing
Relative paths in this skill are relative to the skill directory.
<skill_resources>
<file>scripts/extract.py</file>
<file>scripts/merge.py</file>
<file>references/pdf-spec-summary.md</file>
</skill_resources>
</skill_content>
Permission: none. skill.use has no requires entries. It is a context-injection tool, not a cage-mediated resource.
principal_scope: not set. Any agent (primary or subagent) with discovered skills gets the tool.
File access: the skill's path hint tells the agent where supporting files live on disk (.kaged/skills/<name>/). The agent accesses them via its existing tools with normal project-relative paths. The cage controls access as it does for any other path — if the agent has shell access, it can run bundled scripts; if it doesn't, it can't. This is operator choice, not skill-level policy.
Compatibility: compatible with the standardised Agent Skills format (Cursor, Cline, Claude Code, etc.). Skills authored for those tools work without modification — the frontmatter fields (name, description) are interpreted identically, and supporting files are accessed via normal filesystem paths.
Amendments
2026-06-15 — skill.use tool: ADR-0044 skills integration
Activates the skill namespace reserved by ADR-0033 §1 with the first tool: skill.use. On-demand skill activation per ADR-0044.
- Namespace table gains a
skillrow: domain "Declarative agent capabilities", current toolsskill.use. - New section § Skill tool (
skill.use) documents: dynamic per-agent registration, catalog-in-description pattern,<skill_content>activation result with path hint and<skill_resources>, file access via existing tools, standard-format compatibility. - Dynamically registered, not global. Unlike all other built-in tools,
skill.useis not in the global builtin tool set. It is created per-agent at session start only when discovered skills exist. Agents with no skills have noskill.*tool. - No cage interaction.
skill.usehas norequiresentries. The tool injects text content. File access by the agent after activation is mediated by existing tools and cage constraints — the skill does not grant any new permissions. - Spec amendments required of this change:
project-dsl.mdgains askills:glob array on AgentSpec;agent.mddocuments the skill discovery and activation lifecycle.
Activates the web namespace reserved by ADR-0033 §1 with the first tool: web.search. Multi-provider web search with sequential fallback chain. Provider abstraction and config flow adapted from oh-my-pi's WebSearchTool reference (reference/oh-my-pi/packages/coding-agent/src/web/search/).
- Namespace table gains a
webrow: domain "Web search", current toolsweb.search. - New section § Web search tool (
web.search) documents: provider abstraction (WebSearchProviderinterface,WebSearchParams/WebSearchResponsetypes), provider catalog (Tier 1 direct-API: brave, exa, kagi, jina, searxng; Tier 2 LLM-backed: tavily, perplexity, anthropic, gemini, openai, zai, kimi, synthetic), fallback chain semantics, response formatting, rate limiting, local-config integration, fullweb.searchtool definition with parameters/returns/behavior, and worked examples. - Provider abstraction is uniform. Every provider — whether direct HTTP API or LLM-backed synthesis — implements the same
WebSearchProviderinterface. The operator's conversation LLM and the search provider are decoupled: using Anthropic's web search tool does not require Anthropic as the conversation model. All providers are model-independent HTTP APIs from the daemon's perspective. - Off by default.
web.searchis not in any agent's default tool set, including the primary. Operators opt in per agent via the standardtools:block ("web.search": { enabled: true }). Noprincipal_scoperestriction. - No cage interaction.
web.searchhas norequiresentries. It is a daemon-brokered outbound HTTP call, not a cage-mediated resource. The cagenet.allowon the calling agent does not gate this tool — the daemon makes the outbound call, same trust model as LLM provider connections (per ADR-0011). - Credentials flow. Per ADR-0011, provider credentials are operator-local. The DSL only enables the tool per agent; provider order, API keys, and per-provider timeouts live in
local.toml. API keys are stored flat under[secrets.web]— key is the provider id, value is the API key (e.g.,secrets.web.brave = "BSA-..."). This fits the existing 2-levelsecretsschema with no extension. Missing key → provider unavailable (not a startup error). - Built-in, not MCP. ADR-0043 already provides MCP as an escape hatch (Brave, Exa, Tavily all ship MCP servers).
web.searchis a first-party built-in alongside that escape hatch because (a) the fallback chain is daemon-owned, (b) the response shape is normalised across providers, and (c) credentials configure once per operator, not per project. The two coexist. - Redaction unchanged.
redactSecrets()in@kaged/local-configalready iterates all[secrets.*]sections generically, so[secrets.web]values are auto-redacted. No redactor code change was needed. - Spec amendments required of this change:
local-config.mdgains a[web]section;0033-tool-dot-namespace-convention.md§1 promotesweb.searchfrom "Future tools" to "Current tools".
2026-05-26 — ADR-0022: per-agent tools, kaged.issue.*/kaged.workflow.* namespaces, principal_scope
Per ADR-0022:
ToolDefinitiongainsprincipal_scope?: "root-only"field. Tools with this field are rejected by the DSL parser on non-root agents and by the registry at dispatch time as defense-in-depth.- Namespace table rewritten. The former
kaged.*reserved row is replaced by two concrete namespaces:kaged.issue(6 tools:create,update,comment,transition,list,get) andkaged.workflow(3 tools:trigger,list,status). Both carryprincipal_scope: "root-only". ToolCallContext.callernow encodes tree-position path (e.g."primary","primary.subagents.scraper"). Root-only check iscaller === "primary".cage_policyisnullfor agents withcage: disabled(was: "for primary").- Per-agent tool resolution section added. Documents the resolution chain: built-in registry → role-based defaults → agent's
tools:block →principal_scopeenforcement → cage filter at dispatch. Project-leveltools:block no longer exists; each agent declares its own tool surface. kaged.issue.*tool definitions added:kaged.issue.list,kaged.issue.get,kaged.issue.create,kaged.issue.update,kaged.issue.comment,kaged.issue.transition. Full parameter/return schemas, behavior, and error codes.kaged.workflow.*tool definitions added:kaged.workflow.list,kaged.workflow.trigger,kaged.workflow.status. Full parameter/return schemas, behavior, and error codes.- Failure modes table extended with
principal_scope_violation, issue/workflow not-found errors, invalid transitions, input validation failures, andfile_input_not_supported. - Audit events table extended with
kaged.issue.created,kaged.issue.updated,kaged.issue.commented,kaged.issue.transitioned,kaged.workflow.triggered,kaged.workflow.status_checked. - Tool availability table extended with
kaged.issue.*andkaged.workflow.*rows (root agent only;principal_scoperejection for all other agents regardless of cage type). - Testing notes extended with project management tool tests (31 cases) and principal scope enforcement tests (8 cases).
2026-05-27 — Bridge wiring complete: per-project cache, daemon shutdown, 87 handler tests
All 24 built-in tool handlers are now wired end-to-end in the daemon runtime. This amendment documents the implementation that closes the gap between the spec's bridge architecture sections and running code.
@kaged/nativespackage created. Rust N-API crate (kaged-natives) with 14 exports (grep, glob, fuzzyFind, astGrep, astEdit, summarizeCode, listWorkspace, search, hasMatch, invalidateFsScanCache, getWorkProfile, AstMatchStrictness, FileType, GrepOutputMode). Vendoredkaged-astcrate (from oh-my-pi's pi-ast). Platform-aware TS loader with lazy singleton. Release binary: 93MB (LTO fat + strip + panic abort, 50+ tree-sitter grammars).- File handlers (
file-handlers.ts). 4 handlers (file.read, file.write, edit.text, file.create) using pure Bun I/O. Path-traversal security, binary detection, pagination. - Search handlers (
search-handlers.ts). 3 handlers (search.grep, search.glob, search.ast_grep) calling@kaged/nativesRust N-API.PathCheckdiscriminated union withToolErrorCode-typed codes. 18 tests. - LSP handlers (
lsp-handlers.ts). Unifiedcode.lsphandler with action dispatch (14 actions:diagnostics,definition,references,symbols,rename,rename_file,code_actions,hover,type_definition,implementation,status,reload,capabilities,request) usingLspBridgeinterface.PathCheckpattern withToolErrorCode. 57 tests. - DAP handlers (
dap-handlers.ts). Unifieddebughandler with action dispatch (27 actions:launch,attach,set_breakpoint,remove_breakpoint,list_breakpoints,set_instruction_breakpoint,remove_instruction_breakpoint,data_breakpoint_info,set_data_breakpoint,remove_data_breakpoint,step_into,step_over,step_out,continue,pause,stack_trace,threads,scopes,variables,evaluate,disassemble,read_memory,write_memory,modules,loaded_sources,custom_request,disconnect) usingDapBridgeinterface. ThrowsToolCallError("dap_session_ended")when no bridge. 30 tests. - LSP bridge runtime implemented per § LSP bridge architecture.
lsp-jsonrpc.ts(Content-Length framing,JsonRpcParser,serializeMessage),lsp-client.ts(LspClient—Bun.spawnprocess lifecycle, JSON-RPC request/response correlation,initialize/initializedhandshake,textDocument/didOpen/didChangefile sync,publishDiagnosticscache, idle tracking, graceful shutdown),lsp-bridge-runtime.ts(LspBridgeRuntime— file-extension→server routing, on-demand client spawning, 10-min idle checker, all 8 bridge methods). 14 JSON-RPC tests. - DAP bridge runtime implemented per § DAP bridge architecture.
dap-client.ts(DapClient—Bun.spawn, DAP wire protocol with Content-Length framing + seq-based request/response,initialize/launch/attach/configurationDonesequence, event routing),dap-bridge-runtime.ts(DapBridgeRuntime— runtime adapter resolution fornode/bun, all 9 bridge methods). - Per-project bridge cache (
bridge-cache.ts).getBridgesForProject(projectRoot)lazily createsLspBridgeRuntime+DapBridgeRuntimeper project root.disposeAllBridges()for daemon shutdown.disposeBridgesForProject()for project unload. - Daemon wiring.
primary-runner.tscallsgetBridgesForProject()at dispatch time and injectslspBridge/dapBridgeintoregisterToolHandlersviaToolHandlerDeps.main.tscallsdisposeAllBridges()in the shutdown sequence (afterdaemonServer.stop(), beforebroker.disposeAll()). Implementsheader updated. Changed frompackages/agent-tooling/ (planned)topackages/agent-tooling/,packages/natives/, daemon tool handlers inpackages/daemon/src/runtime/tool-handlers/.- Remaining gaps. File sync tracker (LSP
didChangenotifications for external edits), audit event emission for tool calls. Both are v0.x concerns — the runtime is functional without them.
2026-05-27 — Issue tools, interaction tools, principal_scope enforcement implemented
Three new tool families implemented across @kaged/agent-tooling, @kaged/harness, and packages/daemon:
kaged.issue.*tools implemented (5 tools, not 6).kaged.issue.create,kaged.issue.list,kaged.issue.get,kaged.issue.comment,kaged.issue.transition. The spec'skaged.issue.updatewas dropped — agents cannot edit base issue info (title/body/assignment); they can only comment and transition status. This is an additive-only design: agents file issues, comment on them, and move them through the state machine. Transition requires a comment forrejectedandresolvedstatuses.kaged.ask+kaged.forminteraction tools implemented (2 tools). Structured multi-question interaction (kaged.ask: questions array withid,title,description,options[],multipleflag) and dynamic data collection with file uploads (kaged.form: fields array withname,label,type,required,description; file uploads land inconfig:/tmp/<requestId>/). Both use the checkpoint-like pause/resume flow: session pauses → WS event → operator answers → new run.principal_scope: "root-only"enforcement implemented.ToolDefinitionfield added;ToolRegistry.dispatch()checkscaller === "primary"before permission checks, returnsprincipal_scope_violationerror code. All 7 new tools (kaged.issue.*× 5 +kaged.ask+kaged.form) carryprincipal_scope: "root-only".- Issue handler implementation (
kaged-issue-handlers.ts). 5 handlers withIssueHandlerDeps(storage adapter dependency). Transition state machine:open→{triaged, assigned, in_progress, resolved, rejected}; resolved/rejected→{reopened}; reopened→same as open. Author tracking viaagent:primary.resolvedAt/resolvedBylifecycle on resolved status. 31 handler tests (70 expect() calls). - Interaction signal flow in
@kaged/harness.InteractionRequestedtype +InteractionKind("ask"|"form") added toRunPrimaryResult. Mastra tools built inrunPrimaryclosure with mutableinteractionSignal, injected viakagedToolOverrides. Daemon handlesinteractionRequestedsignal viatransitionSessionToCheckpointwith"interaction:"detail prefix. 5 new runtime tests. - Config scaffolding.
.kaged/.gitignore(ignorestmp/and*.local.*) and.kaged/tmp/directory created ininitProjectDir. - Namespace table updated.
kagednamespace now has 8 tools: checkpoint (1) + issue (5) + interaction (2). Speckaged.issue.updatesection retained for reference but the tool is not implemented — intentional deviation documented above. - Error taxonomy extended.
ToolErrorCodegainsissue_not_found,invalid_transition,principal_scope_violation. ToolHandlerDepsextended withstorage: StorageAdapterfor issue handlers.registerToolHandlerswires all 31 tools (24 original + 5 issue + 2 interaction, though interaction tools are wired at the Mastra level in the harness, not viaregisterToolHandlers).- Test counts. agent-tooling: 141 tests (was 136). harness: 127 tests (was 122). daemon: 862 tests (was 831). Total: 3,119 (was 3,065).
2026-06-04 — shell.bash tool: PTY-backed command execution
New shell namespace with a single tool for agent-driven command execution:
shell.bashtool added. Execute shell commands via the daemon's PTY broker (PtyBroker.spawn()). Parameters:command(required),cwd,timeout,env. Returns:stdout,stderr(always empty — PTY merges streams),exit_code,timed_out. Non-zero exit codes are data, not errors.- Namespace table extended.
shelladded as 6th built-in namespace. Reserved namespace list updated. ToolDefinition.namespaceunion extended with"shell".- Permission model. Requires
{ kind: "capability", name: "shell" }+{ kind: "fs", mode: "rw", path: "caller" }. Primary agent always allowed; caged subagents need explicitshellcapability. - PTY broker integration. Handler delegates to existing
PtyBroker(threaded viaToolHandlerDeps.ptyBroker, not thegetBrokerRef()singleton). Timeout enforcement uses broker's SIGTERM → 5s grace → SIGKILL escalation. - Tool availability table extended with
shell.bashrow. - Failure modes table extended with shell-specific failures: empty command, cwd escape, timeout, output truncation, spawn failure.
- Audit events extended with
shell.executed. - Testing notes extended with 11 shell tool test cases.
2026-06-05 — ADR-0034: kaged.todo tool, todo bubble-up
Per ADR-0034:
kaged.todotool added. Single root-only tool with 7 actions (view,set,add,start,done,drop,note). Operates implicitly on the session's bound issue. Content-addressed task lookup. Single-in_progressinvariant enforced server-side. Two kinds:step(agent working plan) andcriterion(acceptance criteria). Agent cannot drop criteria — only the operator can.- Namespace table extended.
kagednamespace now has 5 tool groups: checkpoint (1) + issue (5) + todo (1) + interaction (2) + workflow (3). - Todo bubble-up section added. Documents how subagents propose checklists via delegation return values, the root reviews and persists via
kaged.todo. Sibling of the issue bubble-up pattern. - Tool availability table extended with
kaged.todorow (root agent only;principal_scoperejection for all other agents). - Failure modes table extended with
no_bound_issue,todo_not_found, and criterion drop rejection. - Audit events extended with
kaged.todo.set,kaged.todo.added,kaged.todo.started,kaged.todo.completed,kaged.todo.dropped,kaged.todo.noted. - Testing notes extended with 16
kaged.todotest cases. - Error taxonomy extended.
ToolErrorCodegainsno_bound_issue,todo_not_found,session_not_found. - Constrained-by list extended with ADR-0034.
2026-06-05: Live todo surfacing: sticky reminder, echo notes, content-addressing coaching
- Sticky reminder. A new ephemeral
system-role message is injected near the tail of the messages array before each LLM call when the session has a bound issue with open todos. The reminder lists up to 5 open items (always includingin_progress), formatted as a markdown checklist. It is not persisted, regenerated fresh each step. Suppressed when the immediately preceding turn already carries akaged.todotool result (preventing redundancy). Implemented inpackages/daemon/src/runtime/sticky-todo-reminder.ts, wired intoprimary-runner.ts. - Echo notes. The markdown output returned by every
kaged.todomutation now includes→ notelines beneath thein_progresstask's notes, giving the agent immediate visibility into its own scratch notes. - Content-addressing coaching. Two new error behaviors on
start,done,drop,noteactions:- ID-like token rejection. If the
contentparameter matches/^(task|todo|item|step|id)-\d+$/i, the tool returns error codecontent_addressing_hintwith guidance to use the task's full text instead of a synthetic ID. - Enhanced not-found. When content-addressed lookup fails, the
todo_not_founderror now includes a hint listing available items when the list is non-empty, or guidance that the list is empty when it is.
- ID-like token rejection. If the
content_addressing_hinterror code added toToolErrorCodeunion in@kaged/agent-toolingtypes.- Failure modes table gains a row:
| Agent uses ID-like token for todo content | Pattern match in handler | Error returned | Agent sees \content_addressing_hint` with guidance to use full task text. |` - Testing notes
kaged.todosection gains: content-addressing coaching tests (ID-like rejection, enhanced not-found with empty-list hint), sticky reminder tests (suppression, windowing, cap at 5).
2026-06-06 — edit.ast tool: AST-aware structural rewrite via ast-grep
edit.asttool implemented. Exposes the existing Rust N-APIastEditfunction as a daemon tool. Unified namespace:edit(notsearch), pairing withedit.textas the two editing modes (exact-string vs. structural). Dry-run by default (dry_run: true); agents must explicitly setdry_run: falseto apply changes to disk.- Tool definition added.
EDIT_ASTinpackages/agent-tooling/src/builtins/file-tools.ts(or separateedit-tools.ts). Parameters:rewrites(required),lang,path,glob,dry_run,max_replacements,max_files. Returns:changes,file_changes,total_replacements,files_touched,files_searched,applied,limit_reached,parse_errors. - Handler added.
createEditAstHandler()inpackages/daemon/src/runtime/tool-handlers/edit-handlers.ts(orsearch-handlers.ts). Resolves project-root-relative path, validatesrewritesis a non-empty object, callsnatives.astEdit()withdry_run: trueby default, transforms nativeAstReplaceResultto daemon response shape. - Registration.
registerToolHandlerswiresedit.astalongsidesearch.grep/search.glob/search.ast.ToolHandlerDepsunchanged. - Default tools updated.
edit.astadded toDEFAULT_ROOT_TOOLSinpackages/dsl/src/defaults.ts(disabled by default, opt-in likeedit.text). - Spec updates.
agent-tooling.md§ Namespace table:editnamespace now has 2 tools (edit.text,edit.ast). Adoption table:ast_edit→search.ast/edit.ast(adapt). Open question #1 resolved. Testing notes: 10 newedit.asttest cases. - Test updates.
builtins.test.ts:FILE_TOOLScount 4→5,ALL_BUILTIN_TOOLS17→18. Newedit-handlers.test.ts(orsearch-handlers.test.tsextension): 10 tests covering dry-run, apply, path resolution, invalid pattern, language inference, max_replacements, max_files, path traversal, parse error collection, single-file vs directory.
References
- ADR-0004 — Bun runtime, FFI for Rust integration
- ADR-0008 — plugin model (tools are NOT plugins; this doc explains why)
- ADR-0009 — seccomp profiles,
ptraceblocking,relaxedopt-in - ADR-0010 — deployment modes tools must work under
- ADR-0011 — project-relative paths tools enforce
- ADR-0022 — recursive agents, per-agent tools,
kaged.*namespaces sandbox.md— cage compiler, seccomp profiles, network gatekeeperplugin-host.md— plugin-declared methods that coexist with built-in tools in the registrysession-manager.md— run model, tool_calls table, PTY brokertask-runner.md— operator-initiated tasks (not agent tools)project-dsl.md—AgentSpec.toolsand cage blocks that drive tool permissionsissues.md— issue schema, status lifecycle, assignment semanticsworkflows.md— workflow schema, invocation lifecycle, tool intersectionhttp-api.md— WebSocket framing for tool call/result streaming- Language Server Protocol: https://microsoft.github.io/language-server-protocol/
- Debug Adapter Protocol: https://microsoft.github.io/debug-adapter-protocol/
- ast-grep: https://ast-grep.github.io/
- oh-my-pi file tools: https://github.com/anthropics/anthropic-quickstarts/tree/main/computer-use-demo
- Bun FFI: https://bun.sh/docs/api/ffi
- Bun
--inspect: https://bun.sh/docs/runtime/debugger