Agents
An agent is the unit you configure and address. It is an immutable
AgentProfile value object — an identity (the system prompt), a model, and a set
of layered access controls — stored as a row in SQLite. Everything about an agent
is data: created and edited at runtime through the DSL, the API, or the
Studio, and every edit is hot — no restart, no redeploy. An in-flight turn
keeps the profile it captured when it started; the next turn sees the new one.
Smallest possible agent — see
examples/hello-agent/:agent = Insika.agent("assistant") do model "deepseek-v4-flash" provider :deepseek instructions "You are a concise, friendly assistant." end puts agent.reply("hi") # => one turn, in-process
Three ways to create an agent
All three land on the same config-over-code import path — they differ only in ergonomics, not in what they produce.
- DSL —
Insika.agent("id") { … }builds an agent definition and imports it into the durable store. Best for code-defined agents and examples. The DSL also names the skill-allowlist policy for you (the tool one is implied by any declared tool list, on every path).modelis optional — a nil model resolves the platformdefault_modelat turn start. - API —
POST /v1/agentswith a definition (a “pack”: an agent config plus its prompt files, skills, and data-tools). The import is idempotent and authoritative — what leaves the definition leaves the agent, so a re-import that drops a tool or skill also removes it.DELETE /v1/agents/:idremoves an agent. - Studio — create and edit an agent by hand in the control UI (Config / Prompts / Skills / Memory / Outcomes / Cache / History / loops tabs), backed by the same commands.
Creating an agent validates its id (required, must be unique) and its subagent graph (cycle/depth — see subagents) before persisting anything.
Addressing an agent
Once it exists, an agent is addressable by id as the model on the
OpenAI-Responses-compatible endpoint:
POST /v1/responses
{ "model": "<agent-id>", "user": "<session-id>", "stream": true, "input": "hello" }
user is the session id (see Context); stream: true streams the
turn as Server-Sent Events.
An optional "origin" declares who wrote the input. Omit it and it means what
it always meant: a customer typed this. Send "engine" when your consumer composed
the message out of context blocks (<memoria> …) rather than relaying something a
person said — the transcript then records it, and a report stops counting your own
injected text as the customer repeating themselves. See
Refinement.
The AgentProfile
A profile is built through one front door — AgentProfile.build(id:, model: nil, …).
model/provider are a straight pass-through to RubyLLM — Insika
keeps no allowlist of “supported” models. Whatever RubyLLM’s installed version
reaches, an agent can name: today that’s 13 provider adapters (Anthropic, OpenAI,
Gemini, DeepSeek, Bedrock, Vertex AI, Azure, Mistral, xAI, OpenRouter,
Perplexity, Ollama, GPUStack) and every model each one exposes — see
rubyllm.com/available-models for the
current, live catalog. Upgrading the ruby_llm gem is the only thing that ever
widens this list; no Insika code changes with it.
Wire a provider’s credentials once in the Studio’s Settings → LLM providers
(or POST /v1/settings) — api is any slug RubyLLM recognizes, and
LLMConfigurator applies it by reflection (<api>_api_key=, <api>_api_base=),
so a provider RubyLLM doesn’t expose an accessor for is skipped, never a hard
error. To restrict which models an agent may use — the opposite direction —
declare model_policy: { allow: [refs] } on its profile (exact "provider/model"
refs or a "provider/*" wildcard); absent means no fence, every configured
model is fair game.
A prompt file is text. Passing a structured value where the markdown belongs —
a {"content": …} wrapper in a pack, or a store entry read and written back — is
rejected, not coerced: to_s on a Hash produces Ruby’s #inspect, and a prompt made
of that is served on every turn while looking healthy.
Its free-form hashes (params, guardrails, sandbox, metadata, …) are
normalized to string keys once, at build time; no reader downstream does dual-key
lookups.
Default limits
DEFAULT_LIMITS = {
turn_timeout: 300, tool_timeout: 60, provider_timeout: 5,
context_budget: 8_000, max_tool_calls: 50, max_tool_repeat: 3,
approval_timeout: 3_600, tool_concurrency: 1
}
build merges your overrides over these — you set only the deltas.
max_tool_calls is announced before it kills. At 10 / 5 / 2 calls remaining
the engine appends a short user message at the next tool-batch boundary
(“2 tool calls left — consolidate what you already have and answer now”), so a
long turn converges on an answer instead of dying with stage: :tool_limit and
delivering nothing. It is a user message, never a system one: the system prefix
stays byte-stable and prompt_caching keeps hitting. Each threshold fires at
most once per turn and emits :tool_budget_warned on the event stream.
max_tool_repeat is the loop guard: the same tool called with identical
arguments that many times in a row gets ONE in-turn warning (a user message at
the next tool-batch boundary: “you already ran this, answer with what you
have”). A repeat after the warning aborts the turn like max_tool_calls does.
Set it below 2 to switch it off.
tool_persistence — don’t give up on the first empty result
The loop guard’s mirror image. max_tool_repeat stops the model from repeating
the same call; tool_persistence stops it from giving up after one weak
call. When ON (the default), the engine appends a short “Tool discipline”
block after the agent’s identity in the system prompt: a weak or empty tool
result means try again with a different approach — a rephrased query, a
synonym, a broader term — before telling the user you found nothing (and don’t
narrate the retries); a tool error means read it and fix the arguments, never
repeat the exact same call; and an action is reported as done only after its
tool call returned success — never before the call, never over a failed or
blocked one. Without it, a search that returns 0 results reads as
final and the model answers “I couldn’t find it” when a synonym one call away
would have.
This is the one default-ON profile flag — every field above is opt-in, this one is opt-out, because the behavior is the proven default and the exception is the thing worth declaring:
tool_persistence false # remove the block for this agent
The block is constant, so it does not invalidate the identity prefix between turns.
prompt_caching — explicit cache control
Set "prompt_caching": true in the agent payload, or enable it in the Studio
agent configuration. There is no dedicated DSL method for this flag.
For Anthropic, this puts the cache breakpoint after the identity layer and leaves volatile system text below it. Other providers receive a plain system string and may cache automatically. See Context for layering, fallback behavior and token accounting.
fencing — third-party text is data, never instructions
fencing true
Off by default. When enabled, the engine sanitizes ordinary tool result string
leaves after evidence processing, memory facts/notes, briefing values and the next
step, the compaction summary, and the knowledge names and descriptions injected in
the context block. It normalizes Unicode (NFKC), removes invisible format characters,
replaces control characters except tabs/newlines/carriage returns, strips known
engine/transcript tag shapes and defuses forged role markers. Tool result strings
are also capped at fencing.max_chars (platform default 12,000); memory and knowledge
context sanitization does not apply that cap. Tool error and halt results bypass
this sanitizer; tool Hash keys, evidence item ids and attachment captions are
unchanged (an id is a key the ledger holds byte-exact).
load_knowledge returns the full body without fencing.
A fixed notice beneath the identity tells the model to treat contextual material
as data. It ships with the flag — the context_providers allowlist does not govern
it. It is stable across turns; it does not guarantee prompt-injection resistance.
Memory/knowledge extraction reads only nonblank user/assistant text regardless
of the flag. Direct tool messages are excluded, but text repeated by the
assistant can still reach extraction. insika doctor warns for unfenced agents
exposed by the configured relay/widget environment settings.
Why some limits are missing from that list
chat_rate_limit, agent_token_ceiling, queue_mode, debounce_ms,
debounce_max_ms, steer_max_messages and steer_join are real limits, and none
of them appears above. That is the rule, not an oversight:
A limit that has a platform-wide layer is absent from
DEFAULT_LIMITS.
Those limits resolve agent → platform (Studio settings) → off, and the agent
layer wins whenever the key is present — including when you set it to nil or
0, which means off for this agent, never inherit the platform value. A
default baked into every profile would make the key present on every agent, and
the platform layer would then apply to nobody.
So the two groups read differently on purpose:
In DEFAULT_LIMITS |
Absent | |
|---|---|---|
| Examples | turn_timeout, tool_concurrency, context_budget |
chat_rate_limit, queue_mode, debounce_ms |
| Absent from your profile means | the constant above | ask the platform, then off |
You set it to nil/0 |
back to the constant | off, platform ignored |
tool_concurrency — parallel tool calls
When the model asks for several tools in one step, they run one at a time by
default. Raise tool_concurrency to overlap independent reads, capped at that
number in flight. Tools marked side_effect still execute one at a time per session:
limit :tool_concurrency, 4 # nil / 0 / 1 = serial (the default); N = at most N at once
One number is both the switch and the cap. It pays off only when a turn issues several slow, independent reads (data tools waiting on HTTP). It buys nothing for fast in-process tools, and it is the model that decides the fan-out, which is why the cap is not optional: an uncapped batch of 15 data tools is 15 simultaneous requests to the same backend.
⚠️ It is silently disabled for any turn that has an approval-required tool. The approval wait is one mailbox per task, so two tool calls suspended for an operator would deadlock — the turn runs serially instead. The downgrade is per turn, not per agent (an agent that lists approvals still gets parallelism on turns where none of the allowed tools require one), and it emits one
provider_warningevent so the lost speedup is never a mystery.
Two behaviours change once it is on — see Tools:
max_tool_calls becomes approximate, and the transcript records tool results in
completion order.
queue_mode — when a message arrives while the agent is busy
A person on WhatsApp rarely writes one message. They write three:
14:02:31 "oi"
14:02:33 "queria saber do pedido"
14:02:36 "1234567"
By default each one is a turn, and they run one at a time. So the agent answers
"oi" with a greeting the customer has already moved past, and may go looking for
an order before the number arrives three seconds later.
Which mode you want depends on when the message arrives:
queue_mode |
The message arrives… | What happens |
|---|---|---|
followup (default) |
any time | it waits its turn in the queue — today’s behavior, named |
collect |
before the turn starts — including while it waits behind another turn | the fragments merge into ONE turn |
steer |
while the turn is running tools | it is appended to the run in flight |
interrupt |
while a turn is running that is now wrong | that turn is abandoned; this message becomes its own turn |
collect — the fragments become one turn
collect merges the fragments that land before the turn starts into a single
turn:
limit :queue_mode, "collect" # "followup" (the default) = one turn per message
limit :debounce_ms, 2_000 # 0 (the default) = no waiting; N = the quiet window
limit :debounce_max_ms, 10_000 # ceiling on the total wait, so typing forever
# cannot postpone the answer forever
With those settings the three fragments above become one turn carrying
"oi\nqueria saber do pedido\n1234567", released 2 s after the last one.
The window is not what opens the door. A turn is “not started” from the moment
it is created until it runs, and that includes the whole time it waits behind the
turn in flight — so with debounce_ms at 0 a fragment still merges into a queued
turn. What the window buys is the case where nothing is running: it holds the turn
at the front of the queue so fragments have something to land on. Without it a
burst on an idle session becomes one turn per message, each answered in order.
All three follow the platform-layer rule above, with one extra rung on top:
session vars → this agent’s limits → the platform default (Studio, queue.*) →
off. Pinning queue_mode in a session’s vars is how an operator takes one
difficult conversation off collect without touching the agent.
⚠️ Your caller has to know it was merged. When the engine coalesces, only one of the three calls owns the reply; the other two answer
200 {"task_id": "…", "merged": true}and stream nothing. A caller that delivers amergedresponse anyway sends the same answer to the customer three times.Because of that,
collectworks only on surfaces that can report the verdict:POST /v1/messages?stream=falseand channel endpoints. On/v1/responsesand on any open stream it is refused and the agent falls back tofollowup— the response body there is fixed by someone else’s wire format and has nowhere to put the field.
Waiting happens inside the engine, not in your request: the POST is acked
immediately with its task_id. Debouncing costs one thing — a customer who sends
a single message still waits out the window before their turn starts, which is
why 2 s is a sane value and 10 s is not.
A merged fragment creates no task of its own, so the record that it arrived separately lives in one event, emitted when the window closes:
{ "type": "turn_coalesced",
"data": { "task_id": "…", "merged": 3,
"arrivals": ["2026-08-07T14:02:31Z", "…:33Z", "…:36Z"] } }
Times and counts, never content. That is what answers “the customer says they sent the order number” without keeping a throwaway task per fragment.
steeralso collects at the door. The two are the same policy’s halves, not two modes: asteeragent merges the fragments that land before the turn starts and appends the ones that land after it —debounce_msonly widens the first half. That pairing is what makes a burst answerable at all: whatever the customer sends either joins the run in flight (steered) or the turn behind it (merged), and the caller hears which one immediately instead of holding a connection open until someone else’s turn ends.
steer — the message arrives while the turn is already running
collect only ever touches a turn that has not started. Once the agent is
running tools, the customer’s next message has nowhere to go but the back of the
queue — so a correction that arrives three seconds into a fifteen-second run is
answered after the run that did not know about it.
steer appends it to the run in flight instead:
limit :queue_mode, "steer"
limit :steer_max_messages, 5 # how many one run may absorb; the 6th becomes its own turn
limit :steer_join, nil # nil = the raw text; a template frames it (below)
Where it lands is the whole design: at a tool-batch boundary, appended at the tail. After the last result of a batch and before the model’s next step — never between two tool results (Anthropic rejects that outright, OpenAI merely tolerates it), and never rewriting a message already sent, which is what keeps the prompt cache valid. So the model sees the correction on its very next step, with the full context of what it has already found.
Reach for steer when turns are long because they call tools. If your turns
are one provider round-trip, collect is the mode that helps and steer has no
boundary to use.
Four cases where the run cannot absorb the message. In every one it becomes the
next turn on the session instead — followup, arrived at late, reported as
turn_steer_released:
| The run… | Why |
|---|---|
| never calls a tool | there is no batch boundary to append at |
ends in halt_when |
there is no next model step; the message would sit unanswered forever |
| is a workflow | a workflow orchestrates the model itself and has no chat to append to |
already absorbed steer_max_messages |
the bound exists so a tail cannot grow without one |
steer_join is for an agent that needs the model to know the text arrived
mid-run. It must contain %{message}, or the config is refused:
limit :steer_join, "the customer just added: %{message}"
Default nil appends exactly what the person typed — and a steered message is a
first-class transcript message, with no origin, because a person wrote it. The
Studio marks it steered in the transcript, derived from its position (a user
message right after a tool result); nothing else in the engine puts one there.
⚠️ Same verdict rule as
collect, different word. The reply comes out of the turn the message joined, so the steered caller is told it does not own it:200 {"task_id": "<the running turn>", "steered": true}, no stream opened. Only surfaces that can carry that verdict may steer —/v1/messages?stream=falseand channel endpoints, never/v1/responsesor an open stream.One consequence worth knowing before you turn it on: when the run cannot absorb the message, the follow-up turn’s reply belongs to no caller. It travels the event stream like any engine-initiated turn (an async subagent’s delivery has the same shape).
steertherefore fits a consumer that reads replies off the stream or off a channel delivery — not one that only reads its own POST response.A steered message also lives in memory until a boundary writes it to the transcript. A hard stop inside that window loses it; a merged fragment, by contrast, is persisted before the window opens.
interrupt — the turn in flight is answering the wrong question
steer assumes the run is still worth finishing. Sometimes it is not: the customer
says “não, esquece isso” while the agent is three tool calls into the wrong order.
limit :queue_mode, "interrupt" # no other knob: see below
The running turn is abandoned and the new message becomes an ordinary turn — its
own task_id, its own reply. That is why interrupt needs no verdict field and works
on every surface, /v1/responses included: nothing joins anything.
What “abandoned” means, exactly:
- The turn terminates
:cancelledand publishes nothing. The answer to the question the customer already replaced never reaches them, and nothing is written to the transcript — so what they read and what the session holds still agree. - A tool call in flight runs to completion and its result is recorded on the
stream. The batch is one unit of work: cancelling the calls that had not started
would leave it half applied, and fabricating failure results would teach the model
that tools failed when they did not. The same boundary bounds
turn_timeout. - The next turn starts from the last committed state. The abandoned attempt is
visible to an operator (its
tool_call/tool_resultevents and the trace), not to the model — a half batch in the history would be an invalid prompt.
No grace knob. An
interrupt_grace_mswas sketched; it is not implemented, and would buy nothing here. The new turn is queued behind the abandoned one either way (one turn at a time per session is the invariant), and waiting for a boundary inside the request would break the ack-fast rule that put the debounce window on the session’s fiber in the first place.
⚠️
context_budgetdefaults to 8000 tokens. A large system prompt (a rich persona can run tens of thousands of tokens) exceeds it, and a pinned identity that overflows the budget fails the turn rather than truncating the identity. If a freshly created agent returns empty turns, raisecontext_budgetfirst. See Context.
The allowlist convention
The same three-state rule governs tools, skills, context providers, and workflows — learn it once:
nil(or absent) = all (opt-in capabilities aside);[]= none;[names]= exactly those.
For tools, a paired deny list (tools_deny) always wins, and
tools_allow_groups unions a per-group allowlist on top of tools_allow.
Declaring a list is opting into it. The lists are applied by one policy, the
builtin tool_allowlist, and the policy engine runs only the policies a profile
names — so declaring tools_allow without naming that policy used to mean the
model got every registered tool. The engine now adds tool_allowlist for you
the moment any of tools_allow, tools_deny or tools_allow_groups is
declared, on every creation path. Presence is the trigger, not content:
tools_allow: [] means “no tools” and enforces just as hard. If a stored agent
predates this, insika doctor names it — the engine repairs it on read, but the
stored record stays wrong until you re-save it.
Three capabilities invert the default — nil/absent means OFF, not “all”:
subagents, memory, and guardrails (each defaults to off or a conservative
setting, never “everything on”). tool_output_compression is a fourth: opt-in
mechanical dedupe of repeated tool results in the history (see
Context),
off by default because it changes what the model sees; fencing is a fifth (see
fencing), off by default. One flag inverts the other way: tool_persistence is ON unless you set it to false (see
tool_persistence).
Declaring what this deployment has
declares "promotions", "human_handoff" records facts about the deployment that
are not tools. It decides nothing at runtime — it exists so an eval case can
say what it needs and be skipped where it is absent instead of failing for the
wrong reason (see Evals). A flat list you write: inferring “this store
has promotions” from data is how a test suite starts lying.
The five access layers
What an agent may do is layered: which tools it may call, which calls need a human, content safety, edge limits on flood and spend, and how much it may think. See Limits and policy.
The stuck signal — “I cannot proceed”
The engine doesn’t decide what “I can’t help you” means — the consumer does. What the engine provides is the deterministic signal, so that a product wanting human escalation can act on it instead of regexing the answer text:
stuck_signal true
With stuck_signal on, the model may call signal_stuck(reason:, message:) when it
determines it cannot proceed (out of scope, missing data, a case a human must take
over). The turn then ends — a final message is published (the model’s lead-in,
or the tool’s message when it wrote none) — and the contract carries the signal
twice:
- the terminal event
task_completedgains an additive sibling"outcome": "stuck"(and the OpenAIresponse.completedframe too), so a consumer that only reads the response can react; - a dedicated
:turn_stuckevent is published with theagent, thereason, and the finalmessage— the subscription point for an operator inbox.
{ "type": "turn_stuck",
"data": { "agent": "store-support", "reason": "order outside my scope",
"message": "I'll transfer you to our human team." } }
Nothing about handing off, pausing, or resuming is in scope here — escalation is a
consumer concern. How a human joins the conversation is exactly what
MessageOrigin.operator (Refinement) stamps
an imported transcript with; this workstream provides the point at which that
handoff is triggered. Off by default (parity): without stuck_signal, the tool
is not wired and the outcome never appears.
Refinement
refinement configures how an agent’s own traffic is read back as a report — what
broke, how often, in which conversations. Unlike the layers above it grants
nothing: a run calls no model and edits nothing, so it needs no opt-in and an
absent key still reports. See Refinement.
refine window: { last_sessions: 200 }, max_findings: 20
Editing the agent from that report is a separate, explicit mode — with a write
allowlist, one or more proposers, a token budget, and a gate that replays the
golden set before anything reaches a human. All of it is in
Refinement; none of it is on until you name it.
Distillation of customer facts
distill configures how finished, idle customer conversations are read back as
proposed facts — the human-gated loop documented in Facts. Pack
data, refinement:’s shape, and absent = off for that agent:
distill enabled: true,
idle_hours: 6, # how idle a session must be before it distills
min_messages: 3, # a shorter session distills noise
max_proposals: 10 # cap per session pass
# prompt: "<what counts as a fact for THIS store>" (the forge's half)
# model: "<ref — absent = the platform utility_model>"
Nothing is ever applied automatically: the engine writes proposals, the
operator approves/rejects/dismisses them on the Studio Facts page (the
latch: a dismissed or rejected tuple is never proposed again), and an approval
writes the fact to the customer’s memory cell stamped
distilled:<session_ref> through an optimistic CAS — an approval never
silently overwrites an operator edit. Sessions are the only candidates, and
the distiller rides the platform utility_model, never a new model slot.
Harvest of skills from real traffic
harvest configures how finished, idle conversations are read back as
proposed SKILLS for the agent’s playbook — the human-gated loop documented
in Harvest. Pack data, distill:’s shape, and absent = off for
that agent:
harvest enabled: true,
negative_list: [ { rule: "no-competitor-prices", pattern: "concorrente" } ],
miner: { model: "deepseek-v4-flash", # absent = the platform utility_model
window: { last_sessions: 200 } },
idle_hours: 24,
min_messages: 3
# prompt: "<what a harvestable skill is for THIS store>" (the forge's half)
The loop reads only finished traffic (the fork is structural — the mining
writes nothing to the sessions it read), filters every proposal through the
negative list and the evidence ledger (product claims must reference IDs the
origin sessions actually saw — an agent without grounding.matcher.sku does
not mine at all), scores survivors with a double gate (the eval replay against
the clone’s golden set, judges mandatory in three shapes; the
conversion “not worse” check against the frozen funnel baseline), and lands
a skill only after a human approves — snapshot-first, append-only
promotion log, deterministic rollback. Nothing is ever applied automatically.
Knowledge from finished conversations
knowledge configures the post-turn learning loop documented in
Knowledge: after a turn completes, the engine extracts durable
concepts — facts, procedures, policies, objections — and persists them for
the agent, separate from any one customer’s memory. Pack data, distill:’s
shape, and absent = off for that agent:
knowledge extract: true,
types: %w[fact policy objection] # what the extractor may emit
# prompt: "<what counts as a concept for THIS store>" (the forge's half)
# model: "<ref — absent = the platform utility_model>"
The model proposes name/description/type/body; the engine stamps
provenance/confidence/sources/timestamps itself (a model-authored one
of those is dropped, never trusted) and redacts the body for PII. Writing a
concept name that already exists never blindly overwrites: the engine
decides same claim (bump the evidence), related claim (merge, one extra
model call), or contradicting claim (never merged — appended under a
heading, confidence drops, a human resolves it in the Studio’s Knowledge
page). Nothing is injected into a turn’s prompt yet — see
Knowledge for what’s not here.
Delegation (subagents)
An agent can delegate to subagents: named child agents it may invoke as a
tool, fanning work out and collecting results. Subagents are opt-in
(subagents defaults to none) and the graph is validated for cycles and depth at
create time. This is off by default because it multiplies model calls — enable it
deliberately.
Delegation only means something when the children are resolvable in the same
graph, which is what Insika.system is for — several agents, one runtime:
system = Insika.system do
agent("security") { instructions "Review code for security issues." }
agent("performance") { instructions "Review code for performance issues." }
agent "reviewer" do
instructions "Delegate to the specialists, then synthesize their reports."
subagents "security", "performance"
end
end
system.reply("reviewer", code) # one turn; the parent fans out and synthesizes
system.serve # all three on /studio + /v1 (each id is a `model`)
When the shape of the work is known in advance — draft then edit, classify then answer, three reviewers then a summary — put the choice in Ruby instead: see Workflows.
The parent gets two system tools: spawn_subagent (one child) and
spawn_subagents (N children in parallel, one combined result — wall-clock
is the slowest child, not the sum, capped by INSIKA_SUBAGENT_FANOUT_CAP,
default 8). A child inherits the environment (model, thinking) as a default and
never inherits capability: its tools, skills and own subagents come from its
own profile.
Where agent data lives
Every agent is a row in one SQLite key-value table (WAL mode), namespaced under
config:agents. The database file is INSIKA_DB. The profile source reads
fresh on each dispatch, which is why Studio and API edits take effect on the
next turn with no restart. See Deploy for the durable-volume setup and
Context for why editing a committed file does not change
a running agent.
Media
Photos, voice notes and documents travel through the message contract as additive content parts, and the engine can hand generated images back the same way. See Media.
Customer-scoped memory and the right to be forgotten
Memory is naturally per customer, not per tenant. A message that carries a
customer key moves the engine’s memory scope to that person:
curl -X POST /v1/messages?stream=false -H "Authorization: Bearer $TOKEN" \
-d '{ "agent": "store-support", "session_id": "chat-7",
"customer": "c-123", "message": "cadê meu pedido" }'
- Scope — with
customerpresent, theremembertool and the<memory>block read/write the[tenant:]customercell: two customers under the same tenant never see each other, and the<request_context>tenant label (the merchant) is untouched. Absentcustomer= today’s per-tenant/per-chat behavior. - Right to be forgotten —
POST /v1/commands/forget_customer(operator) purges the customer’s memory cell, their sessions and everything those sessions left behind — per-session traces, the tasks (the message text lives in the persisted command), their checkpoints (the transcript) and the outbox deliveries (the answer as it was handed to the channel) — and nothing else’s:{ "customer": "c-123", "tenant": "acme" }. Name the tenant: the operator credential carries none, and without one the purge means the whole deployment (the untagged memory cell, plus that customer’s sessions in every tenant) — right for a single-tenant deployment, never what a multi-tenant operator means. Facts also support an optimistic CAS write (replace_if_revision) for an integration that must not clobber a concurrent edit. - Tenant deletion —
POST /v1/commands/delete_tenant_data(operator) purges EVERYTHING the engine holds about one tenant: its sessions and their whole footprint (traces, tasks, checkpoints, outbox deliveries), every memory cell under the tenant (its own + the customer cells — enumerated from the store, so even a cell whose session was already deleted goes), its outcome records and its artifacts (a report is content, never kept behind an offboarded tenant):{ "tenant": "acme" }. Its API tokens are revoked first (before the sweep): an offboarded tenant whose credentials still resolved kept authenticating and could open a new session over the erasure. The tenant string is the isolation boundary; a neighbour is untouched. - Retention — the age-based counterpart, as data: the settings key
retention_days(Integer days; absent/0 = OFF, the engine never sweeps by default). The tick’s daily sweep (at most once per 24 h, behind the same single-key claim the stale-task sweep uses) purges sessions (+traces), terminal tasks (+checkpoints), delivered/failed outbox records, memory facts/notes and outcomes older than the window. A non-terminal task is never touched — the Recovery sweep owns those lives — and neither is a delivery still owed to somebody. One thing the same daily pass sweeps regardless ofretention_days: the budget counter cells whose window already rolled over (and their once-per-window alert markers). Those are engine bookkeeping, not customer content, and nothing else ever collected them. Artifacts (reports) likewise expire on their own knob,artifact_ttl_days(settings; absent = OFF) — the guarantee that PII inside a report dies even in a deployment that keeps its conversations forever. See Artifacts.
Outcomes and follow-ups
What the agent’s traffic was worth in business terms, and the tool that lets it come back later on a promise it made. See Outcomes and follow-ups.
Schedules — recurring turns the engine fires
The operator’s counterpart to follow-ups: a turn nobody sends — the daily
report at 22:00, the eval sweep every night. Declared per agent as pack data
(schedule "daily_report", cron: …, tz: …, message: … or every: N), edited
hot in the Studio’s Schedules config group, fired by the engine’s own tick
one turn per claim window, never queued, no catch-up after a downtime (each
missed window is a recorded skip, visible in the Studio):
agent = Insika.agent("reporter") do
schedule "daily_report", cron: "0 22 * * *", tz: "America/Sao_Paulo",
message: "Run the daily report now.",
overrides: { turn_timeout: 900, max_tool_calls: 200 }
end
The run is a first-class turn stamped origin: "scheduled"; session_mode:
"new" gives it a fresh session per run (the report shape), "fixed" a
standing one; per-run overrides raise the chat-time ceilings a report needs.
A hard calendar budget at its cap skips instead of burning the store’s tokens.
Distinct by shape and by law from the follow-up tool. See
Schedules.
Artifacts — a report the agent can hand you a URL to
A scheduled report’s natural output is not a message but a page — tables,
sections, inline charts. The save_artifact tool (a registry tool, in the
agent’s tools_allow) gives the agent a durable destination: it hands in
title + content and gets a URL back, which it can include in a channel message
(“today’s report:
Review before enabling writes
Does each ID come from the server? Declare evidence on the lookup tool and
requires_evidence on the write tool so the engine enforces session provenance.
Mark writes as side_effect so parallel batches serialize them and recovery skips
completed calls. See Tools.
Do not offer the irreversible action
evidence and requires_evidence stop a write built on an id the customer was never
shown. They do not stop a write the model decides the customer asked for. That is a
prompt problem, and the cross-harness bench measured how
large it is.
One task: the customer has two units in the cart and says “adiciona logo por favor” — ambiguous, and the correct reading is a request to add something, not to check out. Ten rounds per harness, on the same model:
| Harness | Passed | Mentioned closing the order | Called create_order |
|---|---|---|---|
| Hermes | 10/10 | 0/10 | 0 |
| Insika, reasoning off | 9/10 | 10/10 | 1 |
| Insika, reasoning medium | 7/10 | 10/10 | 3 |
| Pi | 5/10 | 9/10 | 5 |
Every harness that offered to close the order sometimes closed it instead, at rates from 10% to 50%. The one that never offered never closed it. It read the ambiguous line as a new request and asked which product — so checkout was never in the sentence the model was continuing.
The rule for a prompt: when the customer’s intent is ambiguous, ask about the
thing they named, and do not volunteer the irreversible next step in the same breath.
An action the reply never proposes is one the model cannot slip into performing. This
is a writing rule, not an engine setting; nothing in the engine needs to change for
it, and no halt_when or allowlist expresses it, because the tool call is legitimate
in every other turn of the same conversation.
Its cost, before you copy it. Asking “which product?” about an item already in the cart ignores context the customer just gave, and reads as an agent that was not listening. The bench rewards it because the bench grades the store’s state. A shop might reasonably prefer the warmer reply and the 10% slip, or split the difference: confirm what is in the cart, and stop there — let the customer be the one who says close it. What you should not do is confirm the cart and offer checkout in the same message, which is the shape that produced every failure above.
Reasoning level does not fix this. The same three tasks fail at medium and at off;
across ten rounds the two settings contradict each other task by task.
The engine’s alternative. If the shop wants the warmer reply and zero slips,
take the write out of the same turn as the offer: declare the tool under
customer_confirm.
The model may then propose closing the order as freely as it likes — the engine holds
the call, the reply asks, and only the customer’s next message runs it. The cost moves
from the wording to the conversation: one extra turn on every legitimate close.
See also
- Tools — define, register, and troubleshoot tools.
- Artifacts — the report destination: the tool, the routes, the signed link.
- Skills — progressive playbooks an agent loads on demand.
- Cross-harness bench — the same store, six harnesses, and where the prompt rule above came from.
- Context — what fills a turn’s prompt, and memory.
- Security — guardrails, sandbox, approvals, edge limits.
- Architecture — how a turn actually runs.
examples/— one runnable project per capability.