Embed it in an agent loop
The core pattern: advertise the tool surface to a model, let the model choose tool calls, dispatch each one with
callTool, and feed the result back.@clarvis/agent-toolsgives you the tools and the dispatch — you own the model call and the loop.
The cycle
An agent loop with these tools is one repeated cycle:
- Advertise — send
tools.listTools()to the model as its tool/function schema. - Model call — the model replies with text and/or tool calls.
- Dispatch — for each tool call, run
tools.callTool(name, args). - Feed back — attach each
{ isError, text }to the conversation as a tool result. - Repeat until the model stops calling tools (or you hit your own budget).
your conversation ──▶ model ──▶ tool calls
▲ │
│ ▼
tool results ◀── callTool(name, args) for each@clarvis/agent-tools owns step 3 only. Steps 1, 2, 4, and the loop are yours — which keeps the package free of any provider or framework dependency.
Wiring the surface
listTools() returns { name, description, inputSchema }[], where inputSchema is a JSON Schema. Most tool-use APIs take exactly that shape, so the mapping is direct:
import { createAgentTools } from "@clarvis/agent-tools";
const tools = createAgentTools({ workspaceRoot: process.cwd() });
const toolSchema = tools.listTools().map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema, // rename the key to whatever your model API expects
}));Dispatching a turn
When the model returns tool calls, dispatch each and turn the result into a tool-result message. The result's isError maps to whatever "this tool call failed" flag your model API uses:
async function runToolCalls(calls: { id: string; name: string; args: Record<string, unknown> }[]) {
return Promise.all(
calls.map(async (call) => {
const res = await tools.callTool(call.name, call.args);
return {
tool_call_id: call.id,
is_error: res.isError,
content: res.text, // feed back verbatim — it is already byte-bounded
};
}),
);
}A few things to know when you feed results back:
textis already bounded. On success it is capped tomaxOutputByteson a UTF-8 boundary, with a truncation marker if it was cut. You do not need to trim it again. See Limits & spill.bashreturns JSON. Its successtextis{ exit_code, stdout, stderr, signal, timed_out }. A non-zeroexit_codeis not an error (isErrorisfalse) — surface it to the model as-is so it can react to a failed build or test.- Errors are self-describing. On failure,
textis a JSON envelope{ "error": "<code>", "message": "…" }. Passing it straight back lets the model correct itself (e.g. fix anambiguous_matchby adding more context toold_string).
Running calls in parallel
dispatch is stateless — a fresh call per invocation — so independent tool calls in one model turn can run concurrently (as in Promise.all above). Writes to the same path are serialized by an in-process lock and each mutation is atomic (temp file + rename), so concurrent edits do not interleave. This is a single-process contract: separate processes or external editors are not coordinated.
Read-only and confinement
By default every path is confined to the workspace root, so a model that hallucinates ../../etc gets a path_escape error instead of reaching the host. If the agent only needs to inspect code, construct the surface with readOnly: true to drop the mutating tools and bash entirely — see Read-only mode. Neither is a substitute for OS-level isolation; see Deploy securely.
See also
- The core API — skip the factory and dispatch against a config directly
- The tools — the arguments and output of each tool you're advertising
- createAgentTools — the
AgentTools/ToolInfo/DispatchResulttypes - Error codes — the
errorvalues a model may need to recover from