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
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

The first eight are platform tools — implemented in AgentToolService and don'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.

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);

// 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