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 or card presentation described by config | an MCP server’s tool, called LIVE |
| Lives | in the deployment image | as a row in SQLite | on the MCP server, behind a live client |
| Editable at runtime | no (shipped in the image) | yes (DSL / API / manifest / Studio) | yes — enable/edit the instance (DSL / CLI / API / JSON import / Studio); the server owns its own tools |
| Reach for it when | logic must run in-process (file edit, shell, subagent) | calling an HTTP API or selecting evidence cards | adopting a whole external MCP server’s toolset |
MCP tools are not data tools. Configuring an enabled MCP instance (any
surface below) is enough — its tools appear automatically, tagged
mcp:<instance>, and each CALL goes straight to the server through a live,
held native RubyLLM client (stdio / Streamable HTTP, with discovery and legacy
handshake fallback) — never a frozen snapshot. See MCP servers below.
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": []
}
{{secret.api_token}} above is only real coming through the manifest
write path (POST /v1/tools/manifest) — writing this same shape via the DSL
or Studio needs the literal header value instead; see
“The one gotcha” below.
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. Two of these resolve at turn time; {{secret.*}} resolves
once, at ingestion — see the gotcha below before reaching for it:
{{param}}— a declared top-level parameter, filled from the model’s call, every turn.{{ctx.*}}— turn context set server-side, never by the model: a closed set ofchat_id,store_id,agent_id,tenant,image_url. This is how a tool knows which session/agent it is acting for without trusting the model.image_urlis the first image part on the message (a photo for analysis outside the prompt); absent when the turn carried none. Resolved every turn, like{{param}}.{{secret.*}}— only resolved on the manifest ingestion path (POST /v1/tools/manifest; see “The one gotcha” below), and only once — the resolved value is what gets stored, the token itself never lives on disk and is never re-read per turn. Allowed only inside a header named insecret_headers. Written any other way — DSL, Studio, or anywhere outside asecret_headersheader — a{{secret.*}}is not a credential the engine knows how to fill; it is an undeclared parameter, and tool registration refuses it exactly like it refuses any other unknown placeholder.
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 serial execution within a session and 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.
Evidence: the lean envelope and grounding
An evidence declaration reshapes a tool result into a lean list and records its
IDs in the session ledger. The ledger supplies the write gate, presentation tools
and optional output grounding below. Declaring evidence alone does not prevent
unsupported claims in the final answer.
{ "name": "search_products",
"response": { "extract": "evidence_envelope" },
"evidence": "products" } // bare kind
{ "evidence": { "kind": "products", // full form
"items": "results", // non-default paths
"attachments": "cards" } }
evidence_envelopeis the canonical extract: the raw response body arrives under an engine-only key, the envelope parsesitems/attachmentsout of it, and nothing re-fattens — the transcript and the tool trace record only the lean result. It requires theevidencedeclaration (refused at load otherwise).- Wire contract — the lean result the model sees is always
{ "items": [ { "id": "…", "line": "…" } ] }(≤ 16 items;linetruncated to 200 chars). A tool whose result has no valid items yields{ "items": [] }, never a null. A malformed evidence result becomes{ "error": … }back to the model — a correctable tool answer, exactly like a malformed call. - Attachments are the optional second half:
[{ "type": "card"|"image", "url": "…", "caption": "…", "id": "…" }](≤ 16, url ≤ 500 chars, malformed dropped). They never reach the model context or the transcript — they ride the channel delivery as an additiveattachmentskey on the outbox payload, and the channel (or its consumer) decides what a card looks like. Supply an explicitidwhen cards are not one-to-one with items in the same order. Without one, an attachment takes its item’s ID at the original position, before malformed cards are dropped. - Lean
linepasses through the tool-result sanitizer only whenfencingis on. Attachment captions are normalized to UTF-8 but are not fenced. See Fencing. - A code tool opts in the same way: it either returns
{ items, attachments }directly and declaresevidencein its registry metadata, or exposes anevidencereader. No declaration = today’s tool behavior, byte for byte.
Provenance: checking IDs before a write
Declare requires_evidence on a data-defined tool to accept only IDs previously
returned by an evidence tool in the same session:
{ "requires_evidence": ["product_id"] }
The full form is { "requires_evidence": { "params": ["product_id"] } }.
The list must be non-empty and name declared top-level parameters. Scalar values
and every element of an array are converted to strings and compared exactly:
SKU-1 and sku-1 are different IDs. IDs typed by a customer do not count.
The ledger includes earlier turns and completed evidence results in the current
turn, capped at the latest 1,000 distinct IDs. Every declared parameter the call
carries must pass before any write occurs; a parameter the schema marks optional
and the model leaves out has nothing to check, a required one left out blocks.
Search first, then write in a later batch: a search and write in the same parallel
batch have no dependency ordering guarantee.
An unknown ID returns status: "blocked", gate: "provenance", the parameter,
the value, and an instruction to search or look it up before retrying. The backend
is never called and no operator approval is requested. A missing ledger blocks the
call too. Omit the declaration to keep the existing behavior; MCP tools do not
support this declaration.
The Studio tool editor exposes requires_evidence. Blocked calls appear in session
traces and insika tools:report, emit tool_blocked with name/gate/parameter only,
and increment insika.tool.blocked. insika doctor warns when an agent allows a
gated data tool without an allowed data tool declaring evidence.
Side effects in parallel batches
With limits.tool_concurrency > 1, tools marked side_effect execute one at a time
within the session’s runtime. Unmarked tools still run concurrently, including
while a write is running. MCP tools are marked side_effect: true unless the server
annotates them readOnlyHint; a read-only POST data tool needs an explicit
"side_effect": false to keep its concurrency.
A queued write holds no concurrency slot. Different sessions remain independent;
backend rules such as quantity limits remain the backend’s responsibility.
The per-tool timeout starts after both gates are acquired. Trace duration includes queueing time, so it measures how long the model waited, not just backend execution.
Customer confirmation: a write the conversation approves
Some writes should not happen on the strength of one ambiguous line — closing an order, deleting a record, sending a message on someone’s behalf. Name them on the agent and the engine holds the call for the customer’s word:
customer_confirm "create_order" # DSL; the Studio field is "customer confirm"
When the model calls a held tool, nothing runs. The engine records a pending action
for the session and returns a pending_confirmation result carrying the arguments
and an instruction: tell the customer exactly what will happen and ask. The turn ends
with that question. On the next message the open hold is rendered at the tail of the
context, and two system tools are available: confirm_pending runs the original call
with the arguments recorded at the hold (no path exists to confirm different ones),
cancel_pending drops it. A hold that the next message neither confirms nor cancels
expires on its own — a customer who changed the subject did not agree.
Three controls now sit on a write, and they compose in this order:
| Control | Who decides | What happens to the turn | Declared on |
|---|---|---|---|
Provenance (requires_evidence) |
the ledger | the call is refused, the model is told how to resolve the id | the tool |
Customer confirmation (customer_confirm) |
the customer, next message | the turn ends with a question | the agent |
Operator approval (approvals_required) |
a person in the Studio | the turn suspends and waits | the agent |
A tool is confirmed by the customer or approved by the operator, never both. The
confirmed run still passes provenance, fencing, the side-effect record and the trace;
the trace shows the hold as gate: confirmation, and the :tool_result event and the
Responses stream report it as status: held, which the evals treat as neither a
success nor an error. Whether the customer’s “sim” means yes is the model’s reading;
what the engine guarantees is that the write runs only after an explicit question, a
customer turn, and an explicit tool call.
Grounding: policing claims against the ledger
With the ledger fed, the pack declares how claims are policed — data on the agent, not a separate code path (see Agents):
grounding mode: :flag, matcher: { sku: '\b[A-Z]{2,4}\d{4,8}\b' }
modeisflag(the default — audit),enforce(cut), oroff. Absent = off.matcher.skuis a regex for the store’s SKU shape, applied to the final answer; every match that is not in the evidence ledger is an ungrounded claim. Grounding is SKU-only by design: a name-based half cannot flag anything without a “this is a product name” signal, so the ledger grounds ids, and the model quoting a returned product by its name is simply outside the check (the SKU path is the claim detector). Askuthat does not compile is refused at build; a matcher with noskubuilds but matches nothing —insika doctorwarns about it.flagappends an:ungroundedflag (categoryungrounded, sourceevidence) to the existing:guardrail_flaggedevent — audit after the fact, like every other output flag.enforcecuts the sentence containing an ungrounded claim from the content the turn persists and delivers, and the flag carriesaction: "cut"so the audit can tell a cut from a flag. It is honest about streaming: on a streaming surface the already-streamed bytes are the channel’s reality, which is exactly why the default isflag— shipenforceonly after a matcher audit proves precision.- Grounding is independent of the guardrails opt-in: an agent with guardrails
off and
grounding.mode: :flagstill gets the check.
Presentation tools: the model picks ids, the engine shows the cards
A presentation tool selects which evidence cards to show. Declare it with
presentation instead of request; it runs in-process and is always
side_effect: false. The model supplies IDs, never card URLs or captions.
{ "name": "present_products",
"description": "Show product cards to the customer. Pass only ids a search returned.",
"parameters": [{ "name": "product_ids", "type": "array:string" },
{ "name": "title", "type": "string", "required": false }],
"presentation": { "component": "product_cards", // what the channel renders
"ids": "product_ids", // the array:string parameter
"max": 8 } } // 1..16 (the attachment cap; default 16)
Exactly one of request / presentation; ids must name a declared array:string
parameter; component follows the tool-name rule. Create it through the DSL,
manifest or API. The Studio form preserves an existing presentation declaration
on save but does not expose its fields. Requested IDs are deduplicated in order
and compared case-sensitively. The engine then:
- keeps only ids the session’s evidence ledger has seen — the rest are dropped
with reason
unknown(an id the model invented, or the customer typed); - joins each kept id to the card an evidence tool returned this session — this
turn’s first, then the last 64 the ledger kept (cards carry the
idof the item they stand for); a known id with no card is dropped with reasonno_card; - truncates to
max— the overflow is dropped with reasonmax; - records the selection on the turn and emits
:uion the stream (published asinsika.uion/v1/responses, as theuiframe on the web channel); - answers the model with
shown(IDs) anddropped(objects withidandreason) — plus one instruction when nothing could be shown (“name the products in text or search again”).
Delivery. When a turn made a presentation call, the outbox attachments are
exactly the presented cards, in call order, each stamped with the call’s component
and title. A turn with no presentation call delivers every hoarded card, as before —
a pack that declares no presentation tool sees no change. An empty selection also
suppresses that fallback: it emits count: 0 and delivers no cards from that call.
An earlier turn’s ID can be shown without a fresh lookup while its card remains
in the session’s 64-card ledger. IDs and cards have separate caps: a known ID can
still return no_card. Cards are stored snapshots, not a stock or price refresh.
A presentation tool needs an evidence source to populate the session’s cards.
insika doctor warns when an agent allows presentation without an allowed evidence
data tool (presentation-tools); it cannot verify a code-tool evidence source. The line that tells the model
when to show cards (“show cards with present_products, ids only”) is the pack’s.
Not here: partial rendering while arguments stream, per-component enrichment (price today, stock — the card is what the evidence tool returned), and a second component such as suggestion chips (same mechanism, when a channel asks for it).
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. Three 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 }.
The one gotcha: env/secret templating is manifest-only
{{env.*}} and {{secret.*}} are substituted at ingestion, on the manifest
path, once — the resolved literal is what gets stored; the token itself
never survives to a turn. Other write paths (DSL, Studio) do
not resolve either: a literal {{env.API_URL}} in a URL fails the
http/https check and 422s; a literal {{secret.X}} anywhere — including
inside a header named in secret_headers — fails tool registration the same
way an unknown parameter would (ToolDefinition.build’s placeholder check
does not special-case it). Rule: manifest tools may template a URL with
{{env.*}} and a secret_headers header with {{secret.*}}; tools written
any other way must ship literal values — a real URL, and a real (masked on
read) header value. {{ctx.*}} and {{param}} work everywhere (they resolve
at turn time, not ingestion).
The Studio Tools screen groups permissions by origin: native tools, HTTP tools, and MCP tools by server instance. Selection remains per tool; grouping does not change tool names or grant permissions. Each section can be collapsed. Filtering by tool or server name opens matching sections without changing selections; clearing the filter restores their state. Section selection buttons affect every tool in that section, including filtered tools, but never override denied tools. Save tools applies the changes.
An MCP instance is a connection, not a single tool. Enable its individual tools for the agent in Studio. Test connection validates discovery with that instance’s credentials; it does not validate a separate HTTP data tool or every remote tool’s execution permissions. When replacing an HTTP tool with MCP, remove the old tool from the agent’s permissions and update prompt references to the actual MCP tool names. Keep the egress allowlist restricted to required hosts.
MCP servers
An MCP instance is durable config — transport, target, credentials, an
enabled flag — held in its own store, separate from data tools. Once an
instance is enabled, its tools appear in the catalog automatically (group
mcp:<instance>, side_effect: true unless annotated readOnlyHint), and every call goes straight to the
server through a live, held client — the runtime never converts an MCP tool
into a stored data tool.
The deployment’s legacy import_mcp_tools command now aliases
refresh_mcp_tools: it returns { instance:, tools: } and emits
mcp_tools_refreshed, replacing the old created/updated import report.
Previously imported data tools remain unchanged and take precedence over live
tools with the same name. After refreshing and verifying the live instance,
delete those snapshots through the normal data-tool controls to use live calls.
insika doctor identifies them by their mcp:<instance> group.
Two supported transports, picked by transport::
| Transport | Target | Notes |
|---|---|---|
stdio |
command + args, run as a child process, env is its process environment |
requires INSIKA_MCP_STDIO=1 — see below |
http |
url + headers (Streamable HTTP) |
egress-guarded; HTTPS required outside loopback |
Legacy sse records remain stored, but cannot connect through the native client.
Change the record to http with the server’s Streamable HTTP endpoint. Event-stream
responses over Streamable HTTP remain supported; only the old SSE transport is removed.
The stdio gate. A stdio instance is arbitrary command execution by
config — it saves, but refuses to start (“stdio disabled by env”) until the
operator sets INSIKA_MCP_STDIO=1 (config-over-convention, the same pattern
as the egress envs). http needs no such gate; its URL is checked by
the normal egress allowlist instead. Native MCP additionally refuses plain HTTP
outside loopback, even when INSIKA_EGRESS_ALLOW_HTTP is enabled.
Credentials are never visible in plaintext. env (stdio) and headers
(http/sse) mask every value as __OCULTO__ on read, everywhere (CLI, API,
Studio). On write, sending the sentinel back preserves the stored value; a
new string replaces it; "" (or omitting the key) clears it — the
same per-key reconciliation llm_providers api keys use.
Discovery vs execution. insika mcp refresh <name> (or POST
/v1/mcp/:name/import, kept as that action’s route since before the live
registry) connects live, lists the server’s tools, and caches the result
(tools_cache) purely for display — the Studio panel and insika doctor.
Execution never reads that cache: a live turn always goes through the
held client, which does its own discovery on first use regardless of whether
refresh ever ran.
Configuring an instance
-
DSL — inside
Insika.system { … }or a singleInsika.agent { … }:mcp "tavily", transport: :http, url: "https://mcp.tavily.com/mcp", headers: { "Authorization" => "Bearer #{ENV['TAVILY_KEY']}" } mcp "filesystem", transport: :stdio, command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]Code is the template: transport/command/args/url/description always follow the declaration on every boot. But once the instance exists, its
enabledflag and its credentials are the operator’s — a Studio/CLI/API edit made after boot is never clobbered back by the next restart.The first boot after a declaration also lists the server’s tools, because until they are listed the agent is offered none of them and answers out of its own head instead. A server that cannot be listed (stdio behind a closed gate, a host that is down) does not stop the boot — it is warned about by name, and the agent runs without those tools until
insika mcp refresh <name>succeeds.What the deployment trusts each tool with — a server describes what its tools do; only the deployment can say which of its answers are evidence and which parameter may only ever carry an id something already returned:
mcp "store", transport: :http, url: ENV.fetch("STORE_MCP"), tools: { # the store's own field names: no server renames them for us "search_products" => { evidence: { kind: "products", items: "products", id: "product_id", line: "line" } }, # a write that may only take an id the customer was actually shown "add_to_cart" => { requires_evidence: ["product_id"] } }Both are refused at ingestion, never at the turn: an
evidence:typo that survived would extract nothing and the gate below it would block every write. A tool nobody names here behaves exactly as it did before. -
CLI —
insika mcp list | add | remove | import <file.json> | test <name> | refresh <name>.addtakes--name,--transport,--command/--arg(repeatable) or--url/--header "Name: value"(repeatable)/--env "KEY=value"(repeatable),--description,--disabled.testconnects live and prints the discovered tools (or the error) without any special setup;refreshdoes the same and additionally updatestools_cache. -
JSON import/export — the same
mcpServersshape every MCP client (Claude Desktop, Cursor, …) already uses:{ "mcpServers": { "tavily": { "url": "https://mcp.tavily.com/mcp", "headers": { "Authorization": "Bearer …" } }, "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] } } }insika mcp import FILE.jsonupserts every entry (a barecommandimpliesstdio; a bareurlimplieshttp; add"transport": "sse"explicitly only to retain a legacy SSE record pending migration). The same parser backsPUT /v1/mcpand Studio’s “Import JSON” box;exportproduces the document back with secrets masked as__OCULTO__, so round-tripping an export never wipes a stored credential. - HTTP API (operator-only, gateway Bearer):
GET /v1/mcp— every instance, masked.GET /v1/mcp/:name— one instance, masked.PUT /v1/mcp— upsert (body = the instance attrs,namerequired).DELETE /v1/mcp/:name— remove (idempotent).POST /v1/mcp/:name/import— refresh (connect live, list tools, cache).
- Studio — the
/studio/mcppanel (create/edit/delete). The form is transport-aware (stdio shows command/args/env, http/sse shows url/headers); each instance shows a status chip (“N tool(s)”, “untested”, “stdio disabled”, or “off”) and its discovered tools fromtools_cache; a “Test connection” button dispatches the samerefresh_mcp_toolsseam asinsika mcp test; an “Import JSON” box takes amcpServersdocument and fans it out into oneupsert_mcpper entry.
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.
Tools marked side_effect still execute one at a time per session; queued writes
acquire that serial gate before taking a shared concurrency slot. See
Side effects. The cap covers every enveloped tool, 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
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 |
An egress rejection returns { error: … } to the model without making the HTTP
request. Inspect the session trace for the actual error; a plausible reply does
not prove the backend ran. Provenance refusals instead report status: "blocked"
and gate: "provenance".
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? - Call refused or failed? Inspect the trace. For
gate: "provenance", look up the ID through an evidence tool before retrying. For an error, check its message for schema, egress, timeout or backend failures. - URL literal? For non-manifest tools, an unresolved
{{env.*}}would have 422’d at import — re-check the definition.
The save_artifact built-in
save_artifact is a registry tool — it obeys the same per-agent
tools_allow as any data tool, and an agent that did not name it cannot call
it (tools_allow: %w[save_artifact]). The agent hands in title + content
and gets the URL back; the tenant is bound from the turn, never a parameter the
model types. See Artifacts for the tool contract, the serving
routes, the signed link and the retention/LGPD reach.
The usage report — insika tools:report
The per-session trace answers “what did this conversation call”; nothing used to
answer “what does this agent carry and never use”. The report aggregates the
stored traces per agent (tasks → sessions → tool_traces, the same read the
Studio does) and flags four shapes:
never_called— intools_allow, zero calls in any stored trace. Dead weight: its schema ships on every request and buys nothing.error_rate— over 30% conventional errors (the trace’sokflag) inside the window (default 14 days). Either the tool is broken or the model cannot hold its contract.stale— called at some point, but not once inside the window.blocked— gate refusals in the window, counted by gate. These do not count as conventional tool errors.
insika tools:report # every stored agent
insika tools:report --agent store-support # one agent
insika tools:report --days 30 --json # wider window, machine-readable
Read-only by design: the report names candidates, the operator removes — a flagged tool may still be the one a rare but critical flow needs. Counts are “at least”, never exact: the trace keeps a capped tail per session.
See also
- Agents — allowlists, groups, and per-agent tool exposure.
- Artifacts — the report destination: the tool, the routes, the signed link.
- 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.