Skip to main content

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-code plugins. The new AgentRunService.execute() calls this.tools.resolveAllowedTools(agent) (see agent-prompt-assembly.md §8) and the returned list is passed to AiFacadeService.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 returns permission_denied consistently.


1. Tool surface

Tool namePurposePermission gateCost
createTaskCreate a task scoped to the Agent's scopepermissions.canAssignTasks1 DB write + 1 activity row
commentOnTaskAppend 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)
transitionTaskMove a Task's statuspermissions.canAssignTasks1 DB write + 1 activity row
editAgentFileEdit one of the Agent's own MD filespermissions.canEditAgentFiles1 Git commit OR 1 DB row + 1 activity row
commitToRepoWrite arbitrary file(s) to the scope's repopermissions.canCommitToRepoN Git commits + N activity rows
openPullRequestOpen a PR against the scope's repo (escalation over commit)permissions.canCommitToRepo AND canOpenPullRequests1 PR + 1 activity row
createSubAgentCreate a new Agent within the parent's scopepermissions.canCreateAgents AND scope cascade rules1 DB write + 1 Git commit (if Git-mode) + 1 activity row
delegateToAgentHand an objective to an ENABLED collaborator and await resultpermissions.canAssignTasks AND collaborator allow-list1 child Task + 1 child AgentRun (child cost is the child's)
getActivityRead recent activity log rows for this Agent's scope(none — always allowed)1 DB read; capped output
getMissionStateRead state summary of a Mission the Agent has access to(none — must be in Agent's scope)1 DB read
getKbDocumentRead a KB document body by slug(none — must be in Agent's scope)1 DB read
getSkillBodyFetch the full body of a Skill (progressive disclosure)(none — must be in active set for this Agent)1 DB or Git read
searchWebWeb search via the active search pluginpermissions.canCallExternalTools AND plugin enabled1 plugin call + 1 PluginUsageEvent
screenshotCapture a screenshot of a URL via the active screenshot pluginsamesame
extractContentExtract content from a URL via the content-extractor pluginsamesame

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 expectedHashprecondition_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 | ERROR
  • message = '<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