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 of chat_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_url is 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 in secret_headers. Written any other way — DSL, Studio, or anywhere outside a secret_headers header — 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:

  • url must be http/https — anything else is a 422.
  • parameters is a safe subset of JSON Schema (object/array/string/number/integer/boolean); oneOf/anyOf/allOf/$ref/ if/then/else are forbidden (not every provider supports them).
  • side_effect defaults 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_path is a dotted path into the parsed response body, and equals a 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 text or json_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_path that 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 say and 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_envelope is the canonical extract: the raw response body arrives under an engine-only key, the envelope parses items/attachments out of it, and nothing re-fattens — the transcript and the tool trace record only the lean result. It requires the evidence declaration (refused at load otherwise).
  • Wire contract — the lean result the model sees is always { "items": [ { "id": "…", "line": "…" } ] } (≤ 16 items; line truncated 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 additive attachments key on the outbox payload, and the channel (or its consumer) decides what a card looks like. Supply an explicit id when 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 line passes through the tool-result sanitizer only when fencing is 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 declares evidence in its registry metadata, or exposes an evidence reader. 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' }
  • mode is flag (the default — audit), enforce (cut), or off. Absent = off.
  • matcher.sku is 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). A sku that does not compile is refused at build; a matcher with no sku builds but matches nothing — insika doctor warns about it.
  • flag appends an :ungrounded flag (category ungrounded, source evidence) to the existing :guardrail_flagged event — audit after the fact, like every other output flag.
  • enforce cuts the sentence containing an ungrounded claim from the content the turn persists and delivers, and the flag carries action: "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 is flag — ship enforce only after a matcher audit proves precision.
  • Grounding is independent of the guardrails opt-in: an agent with guardrails off and grounding.mode: :flag still 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:

  1. 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);
  2. 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 id of the item they stand for); a known id with no card is dropped with reason no_card;
  3. truncates to max — the overflow is dropped with reason max;
  4. records the selection on the turn and emits :ui on the stream (published as insika.ui on /v1/responses, as the ui frame on the web channel);
  5. answers the model with shown (IDs) and dropped (objects with id and reason) — 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):

  1. DSL — data_tool(name:, …) in a Insika.agent { … } block.
  2. Studio — the Tools panel editor.
  3. Manifest — POST /v1/tools/manifest. Partial failure is isolated: one malformed tool becomes an errors[] 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

  1. DSL — inside Insika.system { … } or a single Insika.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 enabled flag 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.

  2. CLI — insika mcp list | add | remove | import <file.json> | test <name> | refresh <name>. add takes --name, --transport, --command/--arg (repeatable) or --url/--header "Name: value" (repeatable)/--env "KEY=value" (repeatable), --description, --disabled. test connects live and prints the discovered tools (or the error) without any special setup; refresh does the same and additionally updates tools_cache.

  3. JSON import/export — the same mcpServers shape 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.json upserts every entry (a bare command implies stdio; a bare url implies http; add "transport": "sse" explicitly only to retain a legacy SSE record pending migration). The same parser backs PUT /v1/mcp and Studio’s “Import JSON” box; export produces the document back with secrets masked as __OCULTO__, so round-tripping an export never wipes a stored credential.

  4. 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, name required).
    • DELETE /v1/mcp/:name — remove (idempotent).
    • POST /v1/mcp/:name/import — refresh (connect live, list tools, cache).
  5. Studio — the /studio/mcp panel (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 from tools_cache; a “Test connection” button dispatches the same refresh_mcp_tools seam as insika mcp test; an “Import JSON” box takes a mcpServers document and fans it out into one upsert_mcp per entry.

Making it appear — and enter the tool-loop

  1. Panel visibility = registered in the catalog. Data tools are marked editable; code tools are allow/deny only.
  2. Per-agent exposure is set from the same panel, or by the agent’s allowlist.
  3. 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.
  4. Deferred tools (tools_deferred) are not offered directly — they appear as a short “available tools” list and the model must call tool_search to 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_calls becomes 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_timeout can overrun by up to tool_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 own tool_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:

  1. Registered? Is it in the catalog (Studio Tools panel)? If not, the write or import failed — check the manifest errors[], and run insika doctor: a stored definition that no longer validates is dropped from the catalog, and the data-tools check is the only place that says so.
  2. Allowed for this agent? In tools_allow (or an allowed group), and not in tools_deny?
  3. 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.
  4. 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 — in tools_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’s ok flag) 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.

Back to top

Insika is MIT-licensed. Reading this as an agent? llms.txt indexes these docs as raw markdown.

This site uses Just the Docs, a documentation theme for Jekyll.