Tools System
MangoStudio supports provider-agnostic tool calling during chat turns. Models can call tools, the system executes them, and results are fed back to the model in a loop.
Architecture
HTTP Layer (tool-settings-routes.ts)
│
Application Layer (tool-settings-service.ts)
│── uses ──→ Registry (registry.ts)
│── uses ──→ Settings Policy (settings-policy.ts)
│── uses ──→ Repository (tool-settings-repository.ts)
│
DB: user_tool_settings
│
Tools Layer (types.ts + registry.ts)
│── registers ──→ Builtins (generate-image.ts, get-current-datetime.ts)
│
Provider Layer (tool-mapper.ts) ──→ Provider-specific wire formats
Tool Lifecycle
- Registration — Tools self-register at import time via
registerTool(). Built-in tools call this at module load. - Settings resolution — At chat time,
getEnabledToolRuntime()loads user tool settings from DB, merges with defaults, and produces enabledToolDefinition[]. - Wire format mapping —
tool-mapper.tsconvertsToolDefinition[]to provider-specific format (OpenAI function tools, Gemini function declarations, etc.). - Model calls tool — The provider streams
tool_call_started/tool_call_arguments_delta/tool_call_completedevents. - Execution —
executeTool()looks up the tool, checks it is enabled for the user, merges settings, and runs the executor. - Result feeding — Tool results are serialized and fed back to the model in the next loop iteration.
- Loop iteration — Steps 4–6 repeat until the model produces a text response or the max iteration limit is reached.
Core Types
ToolDefinition
interface ToolDefinition {
name: string;
description: string;
parameters: JsonSchema; // JSON Schema for the tool's arguments
}
RegisteredTool
interface RegisteredTool {
definition: ToolDefinition;
buildDefinition?: (settings: EffectiveToolSettings) => ToolDefinition;
settings: ToolSettingsMetadata;
execute: (args: unknown, context: ToolContext) => Promise<unknown>;
}
ToolSettingsMetadata
interface ToolSettingsMetadata {
title: string;
description: string;
category: 'system' | 'image' | 'interaction';
enabledByDefault: boolean;
canDisable: boolean;
defaultParameters: Record<string, unknown>;
parameterDescriptors: ToolParameterDescriptor[];
}
Built-in Tools
Chats bind a server-side working directory. Filesystem tools resolve relative
path / cwd arguments against the bound directory, and the agent system
prompt announces it. Tools that take a cwd default it to the bound
directory; a chat with no bound directory falls back to the API process
working directory. Tools such as read_file, list_directory, and grep
still require an explicit path. When restriction is enabled, path
containment policy applies to routed tools.
The path convention runs both ways: a path a tool reports can be passed back
into another tool and reach the same file. Every path in a result list —
grep.matches[].file, glob.matches[] — is relative to the chat working
directory, not to the search root the call named. Two cases report an absolute
path instead, because a relative one would be worse than verbose: a chat with no
working directory bound, and a match that lands outside the working directory.
glob’s absolute setting stays the explicit opt-out and keeps meaning “give me
absolute paths”. Echoed inputs (grep.path, glob.cwd) are not results and are
returned as given or as resolved.
generate_image
Creates one or more images via image generation models during a text chat turn.
- Tool name:
generate_image - Category:
image - Parameters:
prompt(required),count(1–4),quality,model - Settings:
timeoutSeconds(5s–600s, default 30s),maxImagesPerCall,defaultQuality,defaultModel,letAiDecideQuality - Execution: Plans images via
createGenerateImageToolPlan(), streams per-image outcomes, summarizes into a single result. Honors the configuredtimeoutSecondsexecution budget.
get_current_datetime
Returns the current date and time in a requested timezone and locale.
- Tool name:
get_current_datetime - Category:
system - Parameters:
timezone(IANA, e.g.America/Sao_Paulo),locale(BCP 47, e.g.pt-BR) - Execution: Validates timezone, formats via
Intl.DateTimeFormat, returns ISO UTC + localized datetime + offset.
read_file
Reads the contents of a file from disk, as line-numbered text or as raw bytes.
- Tool name:
read_file - Category:
system - Parameters:
path(required, absolute,~-prefixed, or relative to the chat working directory)startLine(optional, 1-based; default1;view: 'text'only)maxLines(optional; default2000, max5000;view: 'text'only)view(optional,text|hex|base64; defaulttext)
- Settings:
allowedPaths,deniedPaths(path lists; enforced byresolveAndValidatePath) - Execution: Reads through a single file descriptor, and the size ceiling bounds the bytes
the descriptor yields rather than the size
statclaims — a file that under-reports its size or grows mid-read is refused at the cap, not read past it. Whole-filesha256is always recorded for the freshness ledger, even on a partial read. Result shape:{ content, path, size, sha256, totalLines, startLine, endLine, truncated }, plusviewwhen it is nottext. view: 'text'(10 MiB ceiling): returns line-numbered (cat -nstyle) content for the requested window and rejects any file with a NUL byte in its first 8 KiB, naming the byte views in the refusal. Per-line and window byte caps may settruncatedand append a notice to usestartLine/maxLinesfor more.view: 'hex'/view: 'base64'(256 KiB ceiling): returns the file’s bytes transcoded, for any file, with no line structure (totalLines: 0) and no windowing — the whole result reaches the model, which is why the ceiling is much lower. A file past it is refused rather than truncated.startLine/maxLinesare rejected alongside a byte view rather than dropped. A byte view records freshness exactly as a text read does, which is what makeswrite_file’s read-before-overwrite guard satisfiable for a binary file — no bypass argument exists onwrite_file.
list_directory
Lists files and directories at a path.
- Tool name:
list_directory - Category:
system - Parameters:
path(required, absolute,~-prefixed, or relative to the chat working directory) - Settings:
allowedPaths,deniedPaths - Execution: Calls
readdir(path, { withFileTypes: true })and returns{ path, entries: { name, type }[] }.
glob
Finds filesystem paths matching a glob pattern, evaluated by Bun.Glob.
- Tool name:
glob - Category:
system - Parameters:
pattern(required, supports*,**,?,[],{a,b},!),cwd(optional base directory; absolute,~-prefixed, or relative to the chat working directory; defaults to the chat working directory, otherwiseprocess.cwd()) - Settings:
allowedPaths,deniedPaths,maxResults(1–5,000; default 200),includeDotfiles(defaultfalse),absolute(defaultfalse) - Execution: Streams matches with
new Bun.Glob(pattern).scan({ cwd, dot, absolute, onlyFiles: false }), stops at the cap, and reportstruncated. - Result paths: re-anchored from
cwdto the chat working directory, so a match can be passed straight intoread_file.absolute: trueopts out and returns absolute paths.
grep
Searches files for lines matching a regular expression.
- Tool name:
grep - Category:
system - Parameters:
pattern(required regex),path(required file or directory; absolute,~-prefixed, or relative to the chat working directory),glob(optional file filter for directory searches),caseInsensitive - Settings:
allowedPaths,deniedPaths,maxResults(1–5,000; default 100),maxMatchesPerFile(default 20),maxFileSizeBytes(default 1 MB),includeDotfiles - Safety: Files containing a null byte in the first 8 KiB are treated as binary and skipped; files above
maxFileSizeBytesare skipped. The probe window is shared withread_file, so a file grep will search is a fileread_filewill open. The regex is compiled withnew RegExpand rejected viaGrepPatternErrorif invalid. - Execution: When
pathis a directory, walks it withBun.Glob(filtered by the optionalglob); for each candidate, reads withBun.file().text(), splits by newline, and records{ file, line, text }matches. - Result paths:
matches[].fileis re-anchored from the search root to the chat working directory, for both directory and single-file searches, so a match can be passed straight intoread_file.
bash / zsh / powershell
Run a shell command and return its captured stdout, stderr, exit code, and timing. The three tools share one implementation (buildShellTool) and only differ by interpreter.
- Tool names:
bash,zsh,powershell - Category:
system - Parameters:
command(required),cwd(optional working directory; absolute,~-prefixed, or relative to the chat working directory) - Settings:
timeoutSeconds(5s–600s, default 30s),maxOutputBytes(1KB–1MB per stream, default 100KB) - Availability: Registered at import time only when the interpreter exists —
bash/zshviaBun.which,powershellonly on Windows (pwshthenpowershell). Unavailable shells are never offered to models. - Safety: Disabled by default (
enabledByDefault: false); requires explicit opt-in. The process is killed withSIGKILLafter the configured timeout, and per-stream output is capped atmaxOutputBytes(flagged viatruncated). Parent abort (user cancel or stream teardown) is tracked separately from timeout and does not surface as a timeout error. - Execution:
runShellCommand()spawns the interpreter withBun.spawn(bash -c/zsh -c/powershell -NoProfile -NonInteractive -Command), enforces the timeout with an owned timer (not Bun’s spawn timeout), reads both streams under the byte cap, and returns a structuredShellCommandResultwith aterminationfield (exited,timed_out,aborted, orsignalled).
Settings Policy
The settings policy (settings-policy.ts) provides pure functions for:
| Function | Purpose |
|---|---|
getDefaultToolSettings(tool) |
Returns defaults from tool metadata |
mergeToolSettings(tool, saved?, updates?) |
Three-way merge: defaults < saved < overrides |
normalizeToolParameters(tool, params) |
Validates parameter names, types, min/max, allowed values |
getToolDefinitionsForTools(tools, settings?) |
Filters enabled tools and produces definitions |
Parameter normalization throws ToolParameterError with a descriptive message on invalid values. The executeTool() function catches this via getSafeEffectiveToolSettings() and falls back to defaults to prevent corrupted saved settings from breaking tool execution.
Long-running builtins (bash, zsh, powershell, generate_image) expose a timeoutSeconds setting (5–600, default 30). The chat execution layer reads this value and cancels the tool call when the budget is exceeded; shell tools also forward the abort signal so timed-out or cancelled child processes are killed instead of orphaned. Shell tools distinguish timeout from parent abort in ShellCommandResult.termination so only genuine timeouts surface the timeout error message. Tools without timeoutSeconds keep the 30-second default.
Tool Settings API
GET /api/settings/tools
Returns all registered tools with their effective settings for the current user.
Response: ToolSettingsListResponse
{
tools: ToolSettingsDescriptor[];
}
PUT /api/settings/tools/:toolName
Updates a tool’s settings (enabled state and parameters).
Request: UpdateToolSettingsBody
{
enabled?: boolean;
parameters?: Record<string, unknown>;
}
Returns 422 with ToolSettingsError if parameters are invalid or the tool cannot be disabled.
Tool Mapper
tool-mapper.ts converts internal ToolDefinition shapes to provider wire formats:
| Provider | Mapper | Format |
|---|---|---|
| OpenAI Responses | toolDefsToResponsesAPI() |
{ type: 'function', name, description, parameters, strict } |
| Gemini Interactions | toolDefsToGeminiInteractions() |
{ name, description, parameters } |
| OpenAI-compatible | toolDefsToChatCompletions() |
ChatCompletionTool[] |
OpenAI Responses API applies strict: true when the derived schema satisfies strict mode
requirements (type: object, additionalProperties: false, all properties required at every
nesting depth, no oneOf/anyOf/allOf/not/$ref/minLength/maxLength).
toStrictSchema produces that dialect at the Responses boundary; source schemas stay plain.
Every built-in tool is expected to pass after the transform. A tool that fails
isStrictCompatible on the derived schema should be fixed rather than exempted —
tests/unit/services/providers/tool-mapper-strict.test.ts asserts strict: true per tool id.
Optional arguments are optional
Author plain JSON Schema: a genuine optional uses a single type and is absent from
required, and minLength/maxLength stay on the source so Anthropic and Gemini can
advertise them. OpenAI Responses has no optional key, so toStrictSchema derives that
dialect at the boundary: every property is added to required, previously-optional keys
become a nullable union (type: ['string', 'null']), and length bounds are dropped. The
executor still enforces the bounds.
// source (plain JSON Schema) // Responses wire (derived)
{ "properties": { "startLine": { { "properties": { "startLine": {
"type": "integer", "minimum": 1 } }, "type": ["integer", "null"], "minimum": 1 } },
"required": ["path"] } "required": ["path", "startLine"] }
The parsing helpers in services/tools/arg-parsing.ts read null as “absent”, so a model
that sends null (Responses strict) and a model that omits the key take the same path.
Numeric minimum/maximum, enum, pattern and minItems/maxItems survive in both
dialects.
Malformed arguments are rejected, not substituted
A tool argument that is present but the wrong type raises ToolArgumentError
(classified validation_failed) rather than falling back to a default. Substituting a value
turns a correctable mistake into a plausible wrong answer: grep dropping a
caseInsensitive: "true" flag returns an empty result set the model reads as “the symbol
does not exist”, and list_directory({"path": 42}) returning the working directory reads as
a successful listing of the directory the model named.
The rule applies to model output only. Stored settings keep coercing to their defaults —
clampIntegerSetting and getStringSetting — because there is no model turn to hand a
correctable error to.
Adding a New Tool
- Create the tool file in
apps/api/src/services/tools/builtin/. - Define
ToolDefinition,ToolSettingsMetadata, and theexecutefunction. - Call
registerTool()to self-register at import time. - Import the tool in
apps/api/src/services/tools/index.tsto trigger registration. - If the tool needs settings-aware behavior, provide a
buildDefinitioncallback. - Add TypeBox schemas for request/response in shared contracts if the tool has its own API surface.
- Write unit tests for the tool executor and the settings merge behavior.
Minimal Example
import { registerTool } from '../registry';
import type { RegisteredTool, ToolContext } from '../types';
const MY_TOOL: RegisteredTool = {
definition: {
name: 'my_tool',
description: 'Does something useful.',
parameters: {
type: 'object',
properties: {
input: { type: 'string', description: 'Input value' },
},
required: ['input'],
},
},
settings: {
title: 'My Tool',
description: 'A custom tool for specific tasks.',
category: 'interaction',
enabledByDefault: true,
canDisable: true,
defaultParameters: {},
parameterDescriptors: [],
},
execute: async (args, context) => {
const { input } = args as { input: string };
return { result: `Processed: ${input}` };
},
};
registerTool(MY_TOOL);