Spec: Agent Tooling
- Status: Draft
- Last amended: 2026-06-06 (
edit.asttool, AST-aware structural rewrite via ast-grep) - Constrained by: ADR-0004, ADR-0008, ADR-0009, ADR-0010, ADR-0011, ADR-0022, ADR-0033, ADR-0034
- 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", "code.lsp"
namespace: "file" | "code" | "debug" | "search" | "shell" | "kaged" | 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";
plugin_name?: string; // if source is "plugin"
principal_scope?: "root-only"; // if set, schema rejects this tool on non-root agents
}
type ToolRequirement =
| { kind: "fs"; mode: "ro" | "rw"; path: "caller" } // needs fs access to caller-specified path
| { kind: "seccomp"; profile: "relaxed" } // needs relaxed seccomp
| { kind: "net"; target: string } // needs network access
| { kind: "capability"; name: string }; // named capability
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 |
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 |
Built-in namespace names (file, edit, search, code, debug, shell, compute, project, kaged) 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.
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, or plugin host).
- Return. Result (or error) returned to the session manager, which records it in
tool_callsand streams it to the WebSocket.
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.*) and plugin-declared tools 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.
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).
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.
Amendments
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