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-chat" 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 auto-enables the tool- and skill-allowlist policies.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 / History 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, …).
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_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.
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 and they run together, capped at that number
in flight:
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 calls (data tools waiting on HTTP) — the wall-clock becomes the slowest call instead of the sum. 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 | 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.
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.
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. RFC-0015 sketched an
interrupt_grace_ms; 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.
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”).
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. Each layer is independent, opt-in where it matters, and editable hot.
Layer 1: Tools (what it can call)
tools_allow / tools_deny / tools_allow_groups decide which tools enter the
turn’s tool-loop, enforced by the tool-allowlist policy. See Tools
for how tools are defined and registered, and examples/data-tool/.
Layer 2: Policies and approvals
Policies are named entries evaluated before the turn runs. Builtins cover
tool-, skill-, and workflow-allowlisting, plus ApprovalRequired — which
does not allow or deny but tags a tool as needing human approval. Set
approvals_required: [tool names]; the gate then fires when the model tries to
call that tool, suspending the turn until an operator approves it in the Studio.
See Security.
Layer 3: Guardrails (content safety)
guardrails configures input/output content safety per agent — opt-in, so an
agent that says nothing gets a conservative default (deterministic detectors on,
LLM moderator off). See Security and
examples/guardrails/.
Layer 4: Edge limits (flood and spend control)
Two independent, opt-in ceilings, enforced before the model is ever called —
opt-in everywhere except on a public channel, where chat_rate_limit is
required and the
web widget refuses to serve without one:
chat_rate_limit— turn attempts per session perchat_rate_window.agent_token_ceiling— total tokens per agent peragent_token_window.
On breach the turn halts gracefully with a configurable limit_response and
zero LLM calls. Windows are set at the platform level; the ceilings can be set
per agent (blank inherits the platform value, 0 explicitly disables it).
⚠️ The token window default is 86400 (daily). To express “500k tokens per hour”, set
agent_token_window = 3600explicitly. A per-agent key that is present but nil reads as OFF for that agent — leave the key absent to inherit. See Security.
Layer 5: Reasoning (thinking)
Controls the model’s thinking budget, resolved by precedence Chat > Agent > Model > Global (first non-blank wins):
| Scope | Where |
|---|---|
| Chat | session var __llm__.thinking |
| Agent | profile.params["thinking"] |
| Model | platform model_params[<ref>].thinking |
| Global | platform thinking |
Values: off | on | low | medium | high. off/on toggle thinking; the effort
levels map to the provider’s thinking-effort parameter. This is a control
primitive, not a latency lever — turning reasoning off does not necessarily speed
up a turn, because most of a turn’s latency is the provider itself, not thinking.
Whether the reasoning ever reaches the customer is a separate switch, off by default:
edge_stream thinking: true, intermediate: false
thinking is the provider’s reasoning; intermediate is the model narrating its
own tool loop (“let me look that up”). Both are always on the event stream for the
Studio and the trace — this decides only whether /v1/responses translates them,
and each opted-in channel gets its own frame type, never the answer’s. See
Architecture.
⚠️ Turn it on knowing your consumer. One that concatenates every text delta into a single message — a WhatsApp adapter — will only be affected once it learns to read the new frames, and when it does, the deliberation is what the customer reads. That is the operator’s call, which is why it is neither a default nor a global.
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.
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.
See also
- Tools — define, register, and troubleshoot tools.
- Skills — progressive playbooks an agent loads on demand.
- 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.