Tools
A tool is a function the model can call inside a turn. Insika has three kinds, and the distinction that matters is who can change one at runtime:
| Code tool | Data tool | MCP tool | |
|---|---|---|---|
| What | a Ruby class (< RubyLLM::Tool) |
an HTTP call described by config, no Ruby | an MCP server’s tool, ingested |
| Lives | in the deployment image | as a row in SQLite | as data-tool rows in SQLite |
| Editable at runtime | no (shipped in the image) | yes (DSL / API / manifest / Studio) | yes (re-ingest) |
| Reach for it when | logic must run in-process (file edit, shell, subagent) | calling an external HTTP API | adopting a whole MCP toolset at once |
MCP tools are not a separate runtime type. An MCP ingestor discovers an MCP
server’s tools and turns each into an HTTP data tool that posts a JSON-RPC
tools/call, tagged with a group naming the source instance. (Only
HTTP-transport MCP servers are ingestible; stdio is rejected.)
Code tools win name collisions — you cannot register a data tool whose name shadows a code tool.
Data tools: a tool is a row
A data tool is defined entirely by config — this is the operator-facing kind, and
the one you create and change without a rebuild. See
examples/data-tool/ for a runnable one.
{
"name": "search_products", // /\A[a-z][a-z0-9_]*\z/
"description": "Search the catalog", // required — this is what the model reads
"parameters": { /* JSON Schema, safe subset */ },
"request": {
"method": "POST", // GET | HEAD | POST | PUT | PATCH | DELETE
"url": "https://api.example.com/search",
"headers": { "X-Session": "{{ctx.chat_id}}",
"Authorization": "Bearer {{secret.api_token}}" },
"query": {}, "body": "…"
},
"response": { "extract": "json_path", "path": "$.results" },
"secret_headers": ["Authorization"],
"side_effect": true, "timeout": 30, "group": "catalog", "tags": []
}
Parameters: the schema is the contract
parameters is JSON Schema, and it reaches the provider verbatim — it is the only
thing telling the model what shape to send. The engine never fills a gap in it.
For simple params there is a flat sugar (what the Studio’s textarea and a hand-written manifest accept), one line per param:
cep | string | required | The ZIP code to look up
tags | array:string | optional | Labels to filter by
quantity | integer | required | How many
Types are string, number, integer, boolean, and array:<scalar> for a list.
There is no bare array: a list without an item type is an incomplete declaration,
and it is rejected instead of being guessed at. A list of objects — the common
[{query, filters}] shape — cannot be written in the flat form at all; write the JSON
Schema, which is what the Studio field reads when the text starts with {:
{ "type": "object",
"properties": {
"query_filter_pairs": {
"type": "array",
"items": { "type": "object",
"properties": { "query": { "type": "string" },
"filters": { "type": "object", "properties": {} } },
"required": ["query"] } } },
"required": ["query_filter_pairs"] }
Arguments are checked against the schema at call time. A call the schema does not
allow never becomes a request: it returns an { error: … } naming the path
(query_filter_pairs[0]: expected an object, got a string), which the model reads and
retries against. Structure is strict; a scalar may arrive in its lossless string form
("2", "true") and is never coerced — what the model sent is what the request carries.
Placeholders are resolved at turn time:
{{param}}— a declared top-level parameter, filled from the model’s call.{{ctx.*}}— turn context set server-side, never by the model: a closed set ofchat_id,store_id,agent_id,tenant. This is how a tool knows which session/agent it is acting for without trusting the model.{{secret.*}}— allowed only inside a header named insecret_headers. A secret placeholder anywhere else is rejected (it would leak unmasked). The real secret value is injected at provision time and never lives on disk.
Validation happens on ingestion. Common rejections:
urlmust behttp/https— anything else is a 422.parametersis a safe subset of JSON Schema (object/array/string/number/integer/boolean);oneOf/anyOf/allOf/$ref/if/then/elseare forbidden (not every provider supports them).side_effectdefaults from the method (GET/HEAD → false, else true) and drives checkpoint/replay semantics (a completed side-effecting tool is not re-run on resume — see Architecture).
halt_when: when the answer is already out
Some tools do the work and deliver the news. A backend that subscribes a customer and sends its own confirmation over the channel has already said everything there is to say: if the model then writes “all set, you’re subscribed!”, the person gets the message twice. The usual patch is to ask the model to stay quiet in the tool’s instructions — which works until the turn it doesn’t, and the failure lands in front of a customer.
halt_when moves the decision from the prompt to the engine. It reads the tool’s own
response, and when it matches, the turn ends right there — no further provider call:
{ "name": "subscribe_to_learning_path",
"request": { "method": "POST", "url": "https://app.example/subscribe" },
"halt_when": { "json_path": "tool_result.status", "equals": ["SUBSCRIBED"] } }
By result, not by tool. The same call that goes silent on SUBSCRIBED must let the
model explain a SUBSCRIPTION_FAILED (“you are already enrolled”) — one tool, two
endings, decided by what the backend actually returned.
json_pathis a dotted path into the parsed response body, andequalsa list of values compared as strings (a status is a label; JSON types vary by backend).- It reads the body, independently of
response.extract— which shapes what the model sees, not what the engine decides on. - It only fires on a 2xx. An error response that happens to carry the value is a failure, and a failure must reach the model.
- A non-JSON body or a missing path simply does not match: a turn never ends on a guess.
A halted turn keeps whatever the model had already streamed before the call (usually a “let me get that for you”) and adds nothing after it.
say: what the customer gets when the model wrote nothing first
The model does not always introduce the call. Then the lead-in is empty, and the turn
used to publish nothing — measured on a real store, two escalation turns in a row
delivered silence to the customer. say is the answer for that turn, and only that
turn: when there is a lead-in it still wins, because two messages for one
escalation is what halt_when exists to prevent.
It cannot be inferred. json_path + equals cannot supply it either — the matched
value is by definition one of the equals tokens, so publishing it would ship
SUBSCRIBED to a person as often as it ships a sentence. So you name it, in one of two
shapes:
// the sentence the backend itself returned
"halt_when": { "json_path": "tool_result.status", "equals": ["SUBSCRIBED"],
"say": { "json_path": "tool_result.message" } }
// a literal the CHANNEL knows how to resolve
"halt_when": { "json_path": "tool_result", "equals": ["…"],
"say": { "text": "CALL_SUPPORT" } }
The literal form replaces the usual workaround: instructing the model to emit a control token and parsing it downstream. The token now comes from the tool’s contract, deterministically, instead of depending on the model complying with a sentence in a prompt.
- Exactly one of
textorjson_path— two answers to “what does the customer get” is a configuration nobody can read, so both (or neither) is refused at load. - A
json_paththat does not resolve to a string publishes nothing: a hash or a number reaching a customer as the answer is never what someone meant. - Omit
sayand the behaviour is unchanged — a halt with no lead-in completes empty, which is what a channel consumer drops.
say is declared on the tool, because what a backend answers is a property of that
backend, not of whoever calls it. Every agent sharing the tool gets the same value.
The Studio’s tool editor does not render this field (nor
group/tags), but a save there preserves it — the form carries the stored values through instead of replacing the record with only what it shows.
Registering a tool
A tool appears in the Studio panel and enters an agent’s tool-loop when it is registered in the catalog and allowed by the agent’s policy allowlist. Four ways to write a data tool into the store — all hot (registry and catalog reload, no restart):
- DSL —
data_tool(name:, …)in aInsika.agent { … }block. - Studio — the Tools panel editor.
- Manifest —
POST /v1/tools/manifest. Partial failure is isolated: one malformed tool becomes anerrors[]entry; only a structural manifest error fails the whole request. The response reports{ version, created, updated, errors }. - MCP ingestion — import a server; each of its tools becomes a data tool.
The one gotcha: env templating is manifest-only
{{env.*}} (and {{secret.*}}) are substituted at ingestion, on the manifest
path. Other write paths do not resolve {{env.*}} — a literal
{{env.API_URL}} there fails the http/https URL check and 422s. Rule:
manifest tools may template the URL with {{env.*}}; tools written any other
way must ship a literal URL. {{ctx.*}} and {{param}} work everywhere (they
resolve at turn time, not ingestion).
Making it appear — and enter the tool-loop
- Panel visibility = registered in the catalog. Data tools are marked editable; code tools are allow/deny only.
- Per-agent exposure is set from the same panel, or by the agent’s allowlist.
- Entering the tool-loop is decided by the policy allowlist, not by tool
type: deny wins, otherwise the agent sees
tools_allow ∪ tools_allow_groups(or all, when both are absent). See Agents. - Deferred tools (
tools_deferred) are not offered directly — they appear as a short “available tools” list and the model must calltool_searchto enable one. This is progressive disclosure for large toolsets — see Context.
Parallel tool calls
A model can ask for several tools in one step. By default the engine runs them one
at a time. Set limits[:tool_concurrency] above 1 (see
Agents) and the calls in that
batch run concurrently, at most N in flight, on the turn’s own reactor — so
the wall-clock of a batch of slow data tools approaches the slowest call rather
than their sum. The cap covers every enveloped tool of the turn, including the
ones tool_search promotes mid-turn.
It applies only to what the model fans out. Two primitives already parallelize
deterministically and are unaffected: spawn_subagents (capped at 8 children) and
Insika::Tools::Concurrency.gather (fan-out inside one tool). System tools —
tool_search, load_skill, remember, spawn_subagent — are not enveloped and
so are not gated by the cap; they are trivial or capped on their own.
Turning it on changes three things, all of them worth knowing before you do:
max_tool_callsbecomes approximate. The limit is checked per call, but a call that trips it does not stop its siblings — the whole batch finishes and the turn then fails. With a cap of 4, up to 3 extra tools may have executed. The turn still fails at the right boundary; the count is just no longer exact.- The transcript records results in completion order. Providers key results by
tool_call_id, so the wire stays valid and persistence is faithful to what was sent — but a replayed transcript no longer reads in call order. turn_timeoutcan overrun by up totool_timeout. A turn deadline does not cancel a tool call already in flight in a sibling fiber; it waits for it. Each call is still bounded by its owntool_timeout, which is what bounds the overrun. Serial execution is unaffected (there, the deadline lands directly in the fiber running the tool).
Approvals and concurrency are mutually exclusive per turn — the approval gate wins and the turn goes serial. That is a deadlock avoided, not a preference.
Egress: the SSRF guard (and its silent failure)
Data tools make outbound HTTP, so every call passes through the EgressGuard, a
Server-Side Request Forgery defense. The default posture is strict: public
https only. Three env vars widen it:
| Env | Effect |
|---|---|
INSIKA_EGRESS_HOSTS |
allowlist of hosts (CSV). The safe way to permit a specific backend. |
INSIKA_EGRESS_ALLOW_HTTP=1 |
permit plain http — loopback dev only |
INSIKA_EGRESS_ALLOW_PRIVATE=1 |
permit private/loopback IPs — dev only |
⚠️ Egress failures are silent. When a tool targets a blocked host (e.g. a plain-
httplocalhost backend without the opt-ins), the guard turns the block into a{ error: … }returned to the model — the request never leaves the process, yet the stream still emits a tool call, so the model narrates a plausible failure and the conversation looks like it worked. You will not see an exception.Always verify by the trace, never by the reply: open the Studio session viewer — a healthy call shows the request, args, and the backend’s
200; a missing or errored call is almost always egress (host not in the allowlist, orhttp/private without the opt-in).
Egress is orthogonal to registration and allowlisting: a tool can be registered, allowed, offered to the model, and still blocked at call time.
Troubleshooting: “the tool is missing”
Work down this checklist:
- Registered? Is it in the catalog (Studio Tools panel)? If not, the write
or import failed — check the manifest
errors[], and runinsika doctor: a stored definition that no longer validates is dropped from the catalog, and thedata-toolscheck is the only place that says so. - Allowed for this agent? In
tools_allow(or an allowed group), and not intools_deny? - Egress? If it appears and is called but “fails”, open the trace — a blocked call is ~99% egress.
- URL literal? For non-manifest tools, an unresolved
{{env.*}}would have 422’d at import — re-check the definition.
See also
- Agents — allowlists, groups, and per-agent tool exposure.
- Plugins — where a code tool comes from, and how to package one.
- Security — egress, sandbox, and approval gating together.
- Architecture — the tool-loop and side-effect checkpointing.
examples/data-tool/— a runnable data tool + the egress note.