Architecture: Agent tools catalog
Status: Draft
Last updated: 2026-05-25
Audience: Engineers implementing the tool-loop in AgentRunService. Defines the canonical set of tools exposed to a user-defined Agent during a run, including arg schemas, response shapes, error envelopes, permission gates, and side-effects.
Every tool listed here is exposed to the AI provider via the existing tool-loop helper that today serves
agent-pipeline/claude-codeplugins. The newAgentRunService.execute()callsthis.tools.resolveAllowedTools(agent)(seeagent-prompt-assembly.md§8) and the returned list is passed toAiFacadeService.createChatCompletion({tools}).Permission gates are evaluated server-side at tool-invocation time. A tool may be visible in the catalog (descriptive only — surfaces in
TOOLS.md) but rejected at call time. The tool returns a structured error to the AI; the AI handles it (usually by asking the user or skipping the action). No "this tool doesn't exist" responses — every gated tool returnspermission_deniedconsistently.
1. Tool surface
| Tool name | Purpose | Permission gate | Cost |
|---|---|---|---|
createTask | Create a task scoped to the Agent's scope | permissions.canAssignTasks | 1 DB write + 1 activity row |
commentOnTask | Append a chat message on a Task the Agent participates in | (none — Agent must be assignee/reviewer/approver) | 1 DB write + 1 activity row + maybe AI dispatch (if mention) |
transitionTask | Move a Task's status | permissions.canAssignTasks | 1 DB write + 1 activity row |
editAgentFile | Edit one of the Agent's own MD files | permissions.canEditAgentFiles | 1 Git commit OR 1 DB row + 1 activity row |
commitToRepo | Write arbitrary file(s) to the scope's repo | permissions.canCommitToRepo | N Git commits + N activity rows |
openPullRequest | Open a PR against the scope's repo (escalation over commit) | permissions.canCommitToRepo AND canOpenPullRequests | 1 PR + 1 activity row |
createSubAgent | Create a new Agent within the parent's scope | permissions.canCreateAgents AND scope cascade rules | 1 DB write + 1 Git commit (if Git-mode) + 1 activity row |
delegateToAgent | Hand an objective to an ENABLED collaborator and await result | permissions.canAssignTasks AND collaborator allow-list | 1 child Task + 1 child AgentRun (child cost is the child's) |
getActivity | Read recent activity log rows for this Agent's scope | (none — always allowed) | 1 DB read; capped output |
getMissionState | Read state summary of a Mission the Agent has access to | (none — must be in Agent's scope) | 1 DB read |
getKbDocument | Read a KB document body by slug | (none — must be in Agent's scope) | 1 DB read |
getSkillBody | Fetch the full body of a Skill (progressive disclosure) | (none — must be in active set for this Agent) | 1 DB or Git read |
searchWeb | Web search via the active search plugin | permissions.canCallExternalTools AND plugin enabled | 1 plugin call + 1 PluginUsageEvent |
screenshot | Capture a screenshot of a URL via the active screenshot plugin | same | same |
extractContent | Extract content from a URL via the content-extractor plugin | same | same |
Everything above searchWeb is a platform tool — implemented in AgentToolService and doesn't go through the plugin facade. The last three are plugin tools — proxied to existing facade services (Search, Screenshot, Content-Extractor) and inherit those plugins' rate limits and errors.
2. Error envelope (shared by all tools)
Every tool either returns its success shape (see per-tool sections below) OR a structured error:
type ToolError = {
error: {
code:
| 'permission_denied'
| 'not_found'
| 'validation_failed'
| 'budget_exceeded'
| 'rate_limited'
| 'precondition_failed'
| 'provider_error'
| 'internal_error';
message: string; // human-readable; safe for the AI to relay
details?: Record<string, unknown>;
};
};
The AI sees the error and decides how to react (retry, ask the user, skip). Errors are never silently swallowed.
3. Per-tool specifications
3.1 createTask
input: {
title: string; // 1..200 chars
description?: string; // markdown, 0..50_000 chars
priority?: 'p0' | 'p1' | 'p2' | 'p3' | 'p4'; // default 'p3'
labels?: string[];
assigneeAgentSlugs?: string[]; // resolved server-side; rejected if out of scope
assigneeUserIds?: string[]; // for human assignees
parentTaskSlug?: string; // optional sub-task
blockedBySlugs?: string[]; // optional blockers
scope?: { workId?: string; missionId?: string; ideaId?: string }; // defaults to parent agent's scope
}
output: { slug: string; id: string; status: 'backlog' | 'todo' };
Side-effects: insert tasks row + N task_assignees + M task_blocks + 1 activity_log (TASK_CREATED). If any assignee is an Agent and the Task is created in todo status with auto-start, dispatch agent-task-execute runs.
Permission gate: canAssignTasks = true. Cross-scope assignee rules per architecture §3. Out-of-scope assignee → permission_denied with details.outOfScope: <slug>.
3.2 commentOnTask
input: {
taskSlug: string;
body: string; // markdown, 1..10_000 chars
mentions?: { type: 'user' | 'agent'; slug: string }[];
}
output: { messageId: string };
Side-effects: insert task_chat_messages row + 1 activity_log (TASK_COMMENTED). If any mention is an Agent assignee on the task, dispatch agent-chat-reply run (deduped by chatMessageId).
Permission gate: caller Agent must already be on the Task (assignee/reviewer/approver). Otherwise permission_denied.
3.3 transitionTask
input: {
taskSlug: string;
to: 'backlog' | 'todo' | 'in_progress' | 'in_review' | 'blocked' | 'done' | 'cancelled';
reason?: string; // shown in activity log
}
output: { status: typeof input.to };
Side-effects: update tasks.status (validated by TaskTransitionService) + 1 activity_log (TASK_UPDATED). On → done: validate approvers if any.
Permission gate: canAssignTasks = true. State machine violations → validation_failed.
3.4 editAgentFile
input: {
name: 'SOUL.md' | 'AGENTS.md' | 'HEARTBEAT.md' | 'TOOLS.md' | 'agent.yml';
body: string; // ≤ 64 KB
expectedHash?: string; // optional ETag for optimistic concurrency
commitMessage?: string; // optional; default "chore(agent/<slug>): self-edit"
}
output: { newHash: string };
Side-effects: Git commit (Mission/Work scope) OR DB write (Tenant scope) + update agents.contentHash + 1 activity_log (AGENT_FILE_EDITED).
Permission gate: canEditAgentFiles = true. At most one file edit per run (frequency cap). Secret scan on body — reject on match. Hash mismatch with expectedHash → precondition_failed.
3.5 commitToRepo
input: {
files: { path: string; content: string; mode: 'add' | 'modify' | 'delete' }[];
commitMessage: string;
branch?: string; // defaults to repo default branch
targetRepo: 'data' | 'website' | 'mission'; // implicit by Agent scope
}
output: { sha: string; branch: string };
Side-effects: 1 Git commit + N file changes + 1 activity_log (new type AGENT_COMMITTED_TO_REPO).
Permission gate: canCommitToRepo = true. Path validation prevents traversal (..). Files must be under the Agent's scope tree — Agents cannot commit outside their scope's repo.
3.6 openPullRequest
input: {
files: {
path: string;
content: string;
mode: 'add' | 'modify' | 'delete';
}
[];
branchName: string; // new branch
title: string;
body: string; // PR description
targetRepo: 'data' | 'website' | 'mission';
}
output: {
prUrl: string;
prNumber: number;
}
Side-effects: create branch + N file changes + 1 PR + 1 activity_log (new type AGENT_OPENED_PR).
Permission gate: canCommitToRepo = true AND canOpenPullRequests = true. The latter exists as a deliberate escalation — committing is per-Agent default; PR review involves humans more directly.
3.7 createSubAgent
input: {
name: string;
title?: string;
capabilities: string;
scope: 'tenant' | 'mission' | 'idea' | 'work';
targetId?: string; // mission/idea/work id; null for tenant
aiProviderId?: string;
modelId?: string;
heartbeatCadence?: string; // cron or 'manual'
permissions?: Partial<AgentPermissions>;
}
output: { agentId: string; slug: string };
Side-effects: insert agents row in status draft (sub-agents NEVER auto-activate; user must explicitly Start) + scaffold MD files in scope repo + 1 activity_log (AGENT_CREATED with details.createdByAgentId).
Permission gate: canCreateAgents = true. Scope cascade per architecture §3: tenant→any, mission→same Mission, work→same Work. Permissions on the new sub-Agent default to false for every flag — caller cannot set permissions ON for the child.
3.8 getActivity
input: {
since?: string; // ISO datetime; default last 24h
types?: string[]; // filter by ActivityActionType; default all
limit?: number; // 1..100; default 20
}
output: {
rows: { id: string; actionType: string; createdAt: string; summary: string; details?: object }[];
truncated: boolean;
};
Side-effects: none.
Permission gate: none. Scoped by Agent's reach automatically — only rows whose workId/missionId/ideaId is in the Agent's scope are returned.
3.9 getMissionState
input: {
missionSlugOrId: string;
}
output: {
id: string;
title: string;
description: string;
status: 'active' | 'paused' | 'completed' | 'failed';
ideas: {
open: number;
queued: number;
building: number;
done: number;
failed: number;
}
worksCount: number;
spend: {
currentSpendCents: number;
capCents: number | null;
periodEnd: string;
}
}
Side-effects: none.
Permission gate: Mission must be in Agent's scope (Mission-scoped Agent reads own; Tenant Agent reads any member Mission). Otherwise not_found (don't reveal existence).
3.10 getKbDocument
input: {
slug: string; // KB document slug
workId?: string; // ambiguous slug across Works requires this
}
output: { id: string; title: string; body: string; updatedAt: string };
Side-effects: none.
Permission gate: caller must be in scope of the KB's owning Work/Mission. KB documents may be poisoned with prompt injection — see security-agents-skills-tasks.md §3 for sandboxing.
3.11 getSkillBody
input: { slug: string };
output: { name: string; description: string; body: string; allowedTools: string[]; examples?: { input: string; output: string }[] };
Side-effects: emit SKILL_INVOKED activity row.
Permission gate: Skill must be in the Agent's active set (resolved per the binding hierarchy). Otherwise not_found.
3.12 Plugin tools (searchWeb, screenshot, extractContent)
These pass through to the existing facade services (SearchFacadeService, ScreenshotFacadeService, ContentExtractorFacadeService) with providerOverride = agent.aiProviderId ?? undefined and userId / workId from the Agent context. The facade resolves the plugin via the same 3-tier cascade. Cost/usage rows are written via the existing PluginUsageEvent flow with agentId set.
Input/output shapes are identical to today's facade call signatures and are not duplicated here. Reference: ai-facade.md and the per-capability service in packages/agent/src/facades/.
Permission gate: canCallExternalTools = true + the plugin must be enabled at the Agent's scope.
3.13 delegateToAgent (Agent Collaborators)
input: {
targetAgentId?: string; // one of the two is required
targetAgentSlug?: string;
objective: string;
context?: Record<string, unknown>; // structured inputs for the child
}
output: SubAgentDelegationResult; // status completed|failed|refused|escalated
Side-effects: one child Task (createdByType='agent', parentTaskId recovered from the parent run) + one child AgentRun dispatched through TaskTransitionService.dispatchAgentRun, then the call WAITS for a terminal state (5-minute budget).
How context reaches the child: the child's Task description IS the delegation's only channel into the child's prompt, so SubAgentDelegationRunnerService.describe() inlines request.inputs under an Inputs: heading, JSON-serialized and truncated past 4000 characters with an explicit marker. Anything omitted from that description never reaches the child at all.
Permission gate: canAssignTasks = true — delegating raises a Task and a run, the same capability class as createTask / run_workflow_graph. On top of that, the target must be an enabled collaborator of the parent (agent_collaborators, Agents → Collaborators tab). Targets are resolved against SELF + the enabled collaborators only, so an unknown id is never probed against the agent table; the error names the current enabled roster so the model can self-correct.
Enforcement: the allow-list is checked server-side in SubAgentDelegationRunnerService — the one choke point every delegation path funnels through — via the pure evaluateCollaboratorDelegation helper in @ever-works/contracts. A disallowed child is a REFUSAL with code collaborator-not-allowed, not a failure. With no rows configured an agent may delegate only to itself (the legacy default). An archived child is refused with the same code even when an enabled rule still names it: ARCHIVED is terminal and unarchive lands on PAUSED precisely so a restored agent does not resume running by itself, and nothing downstream of dispatch re-checks agent status. The tool drops archived agents from the roster it offers the model for the same reason.
4. Tool list assembly at run time
The set of tools registered with the AI call for a given run is computed by AgentToolService.resolveAllowedTools(agent):
const tools: ToolDefinition[] = [];
// 1. Always-on read tools
tools.push(getActivity, getMissionState, getKbDocument, getSkillBody);
// 2. Permission-gated platform tools
if (agent.permissions.canAssignTasks) tools.push(createTask, transitionTask);
tools.push(commentOnTask); // gated at call-time by Agent's task participation
if (agent.permissions.canEditAgentFiles) tools.push(editAgentFile);
if (agent.permissions.canCommitToRepo) {
tools.push(commitToRepo);
if (agent.permissions.canOpenPullRequests) tools.push(openPullRequest);
}
if (agent.permissions.canCreateAgents) tools.push(createSubAgent);
// Agent Collaborators — also requires the delegation service + collaborator
// repository to be bound in this runtime (both are @Optional()).
if (agent.permissions.canAssignTasks) tools.push(delegateToAgent);
// 3. Plugin tools (only when external calls allowed AND plugin enabled in scope)
if (agent.permissions.canCallExternalTools) {
if (await isPluginEnabled(agent, 'search')) tools.push(searchWeb);
if (await isPluginEnabled(agent, 'screenshot')) tools.push(screenshot);
if (await isPluginEnabled(agent, 'content-extractor')) tools.push(extractContent);
}
return tools;
The exact ToolDefinition shape matches the LangChain tool format already in use by agent-pipeline plugins, so the AI provider needs no special handling.
5. Telemetry per tool call
Every tool invocation emits one row in agent_run_logs with:
step = 'tool:<toolName>'level = INFO | WARN | ERRORmessage = '<short summary or error code>'metadata = { args (truncated 1 KB), durationMs, costCents (if plugin-tool) }
This is read by the Agent's Activity tab and surfaces in Sentry on errors.
6. References
agents-skills-tasks.md— overall architecture.agent-prompt-assembly.md— where tools fit in prompt assembly.security-agents-skills-tasks.md— threat model + secret-scan + path-traversal mitigations.../features/agents/spec.md— Agent product spec.../features/task-tracking/spec.md— Task product spec (createTask/transitionTask/commentOnTask consumers).- ADR-006:
../decisions/006-agents-skills-tasks-as-core-not-plugins.md.