How an agent does something instead of only talking: the <SYSTEM CALL> protocol, the two kinds of tool, and the one contract you must keep so the answer reaches the agent.
Get this one idea right and most tool confusion never happens.
A language model can do exactly one thing: produce text. It can't open a connection, read a record, or look at the clock — everything it knows was in its prompt. A tool is how an agent steps outside that limit, and it works the only way it can: the model writes a small block that is meant for the platform, not the person; the platform recognizes it, runs some code you wrote, and puts the result back into the conversation as a new message. The model is re-run and now sees the data as if the user had handed it over.
return work" — comes from someone thinking "function".Because a call is a whole round of the model, it is also a cycle, not a single step. The model emits a block, the platform runs the tool and appends the result, then the model runs again with the grown conversation — and the cycle ends the instant the model replies with plain text. Two things follow at once: each tool costs a full model round (three tools ≈ four model runs; time and tokens grow with it), and the current exchange's tool turns are kept in full so the model can see what it just did — the practical cap on prior history is about 50 turns.
one question, one tool — the agent node appears twice (two rounds)
▸ flow — the main flow
startNode
agentNode → <SYSTEM CALL> Current Weather {"city":"Sofia"} round 1
agentNode → "It's overcast in Sofia right now…" round 2
endNode
▸ flow — the tool, a separate virtual flow (its OWN session)
virtualStartNode
virtualScriptNode tools/weather.js
virtualEndNodeNotice the two facts that shape everything below. The Agent node appears twice in the main flow — those are the two rounds. And the tool runs as a separate flow with its own session — which is exactly why replying to the caller takes a specific session stamp (the section "How the Answer Returns").
Most lost time comes from expecting the platform to do something it doesn't.
The split is narrow and worth knowing by heart. The platform parses the <SYSTEM CALL> block out of the model's reply, looks the !*tool name up literally in your registry, runs the tool, re-runs the agent afterwards, and synthesizes a short message if the block is malformed or the tool is missing. You write three things — and keep the tool talking back.
<SYSTEM CALL> block · routes by the !*tool name looked up in the registry · runs the tool and re-runs the agent · synthesizes a recovery message on a bad or absent block.keen-tools.json · write the implementation — and make sure it speaks back · describe what each tool does and when it's used.{ name, type, entry } — there is no description field. The platform never tells the model which tools exist, what they do, or when to reach for them. If you don't write that catalogue yourself in the system prompt, the agent simply never calls anything.Every tool, from the simplest to the most complex, is always these same three — miss one and nothing works, usually silently.
1 — The registry. src/keen-tools.json tells the platform what exists and how to start it. The name is matched literally against the !*tool field — spaces and capitals included. A script entry is a path under tools/; a flow entry is a File-Label.
src/keen-tools.json
{ "tools": [ { "name": "Current Weather", "type": "script", "entry": "tools/weather.js" }, { "name": "Multi Answer", "type": "flow", "entry": "MultiAnswer-Start" } ] }
keen-tools.json and the agent still reports The requested tool does not exist, the project almost always wasn't re-deployed.2 — The catalogue in the system prompt. This is where you write what a tool does and when to use it — the platform does not do this for you. The minimal working catalogue is a format plus a list, and the "when" matters as much as the shape of the context.
the catalogue you add to the system prompt
## Calling a tool When you need a fact you don't have, write a block between <SYSTEM CALL> and </SYSTEM CALL>, exactly like this: <SYSTEM CALL> !*action use tool !*tool Current Weather !*context {"city": "Sofia"} </SYSTEM CALL> The markers and every !* start on a new line, no indent. At most ONE block per turn — only the first one runs. ## Your tools ### 1. Current Weather - What it does: current weather for a city — temperature, feel, humidity, wind. - When to use it: the user asks about weather right now, in a named place. - Context: {"city": "Sofia"}
Place Lookup instead of Current Time — because its catalogue listed only names and schemas, no descriptions. The fix was one sentence: a place name says WHICH place — the question itself says WHAT is being asked about it.3 — The implementation. A script or a whole flow. Whatever it is, its one job is to speak back to the agent — which is the section "How the Answer Returns", the most important one here.
The only thing the platform reads out of the model's reply.
the block the model emits
<SYSTEM CALL> !*action use tool !*tool Current Weather !*context {"city": "Sofia"} </SYSTEM CALL>
<SYSTEM CALL> … </SYSTEM CALL>!* begin at the start of a line, no indent.!*toolname in the registry.!*action · !*context!*whatever is captured and passed along too.Four behaviors are worth knowing before you write the prompt:
!*action does not route. Whether the tool is a script or a flow is decided by the type in the registry. You could write use tool flow above a script tool — nothing changes.Tell the agent in the prompt: if you need a second tool, wait for the first result and call it on your next turn.
The two look identical from the outside — the model emits the same block. Inside they are different things.
| Script tool | Flow tool | |
|---|---|---|
type | "script" | "flow" |
entry | tools/weather.js | MultiAnswer-Start |
| The implementation is | one JS file with an exec | a whole graph of nodes |
| Can it call an LLM | not directly | yes — with an Agent node inside |
| Branching | only with if in code | Condition, Loop, parallel nodes |
| Reply complexity | low — one session | high — up to three sessions |
The true difference is not "simple vs complex" — it is a function vs a process. A script tool is one function: call an API, transform data, compute something; the result is deterministic, no judgement is needed, and there is one session to think about. A flow tool is a process: several steps, branches, its own agents; somewhere inside it needs judgement — a second LLM — or parallelism or a loop over a collection; and there are three sessions to track. The practical rule: start with a script tool. Move to a flow tool only when you need a second model inside the tool or a structure a single file can't express. A flow tool is not a more powerful script tool — it is a different instrument with a noticeably higher maintenance cost.
system/agent, so it can read one agent's answer and feed it as a prompt to another. The real reason to reach for a flow tool is structure: branching, waiting, parallelism — things a graph expresses and a single file does not.The heart of the whole page. If you remember one section, make it this one.
First: return is not the answer. The natural reflex is wrong — a returned value only ends the script's execution; it is never handed to the model. If that is all you do, the agent reports No tool response — even though the tool ran, called the API, and got the data.
the wrong reflex — the value never reaches the model
export const exec = async () => { const data = await Http.getJson(url); return { weather: data }; // ends the script; the model never sees it };
The answer travels through the conversation. The tool pushes a new user-role message to the agent that called it, via system/agent:
the answer is pushed back to the caller
import Agent from 'system/agent'; import Tools from 'system/tools'; export const exec = async () => { const call = toolOptions.toolContext; Agent.setRootAgentPrompt(call.agentId, { prompt: '<WEATHER_RESULT>\n' + JSON.stringify(result) + '\n</WEATHER_RESULT>', sessionID: toolOptions.agentNode.flowContext.sessionId, requestID: Tools.generateUUID() }); return { done: true }; // just ends cleanly };
Second: the session stamp. The agent looks for its prompt scoped to its own session. A message pushed without the right session is written to the conversation store but stays invisible to the re-run agent — it finds only the original question, and the platform synthesizes No tool response. The tool ran, the data arrived, the logs prove it, and the agent still says there is nothing. That is the most expensive hour you can lose if you don't know about it. Three details each break it silently:
| Detail | Correct | Silent mistake |
|---|---|---|
| Capitals on the write | sessionID: · requestID: | sessionId: — silently ignored |
| Lowercase on the read | flowContext.sessionId | — |
| Where the session comes from | toolOptions.agentNode.flowContext.sessionId | flowContext.sessionId — that's the tool's own session |
Yes — you read with a lowercase d and write with a capital D. It looks like an API slip, and it is, but it is the real contract.
Third: a fresh requestID. Every pushed message gets a new UUID via Tools.generateUUID() — the sandbox has no crypto, so this is the way. Reuse an old requestID and the message looks like the same turn rather than a new one.
Fourth: a clean return. Writes to the agent accumulate in the sandbox and are flushed only on a clean return. If the script throws after setRootAgentPrompt, the accumulated writes are discarded — and the agent again gets nothing. So wrap risky work in try/catch, push a message on the error path too — not only on success — and finish with a plain return { done: true }.
one reply() helper — every exit goes through it
const reply = (payload) => { Agent.setRootAgentPrompt(agentId, { prompt: '<TOOL_RESULT>\n' + JSON.stringify(payload) + '\n</TOOL_RESULT>', sessionID: agentSessionId, requestID: Tools.generateUUID() }); return { done: true }; };
reply. Then it is impossible to miss the session stamp in some rare branch.For a flow tool the rule is identical — some node inside the flow does it, usually a last Script node before End. What differs is where it gets the caller's identity, which is the next section.
Two sessions for a script tool don't tangle easily; three for a nested flow tool almost certainly will without the map.
toolOptions.agentNode.flowContext.sessionId — only in the first node. Used for the final answer back to it.flowContext.sessionId. Used to seed a prompt for the inner agent.toolOptions.agentNode… = the inner agent. Used to answer the inner agent.{ toolContext, agentNode } bundle for the nodes after it — so the script that returns the final answer may no longer see who the original caller was.The fix is architectural, not a workaround: capture the caller's identity in the first node and leave it in the dictionary, which survives the cycle — then read it in the last node.
setup.js — the first Script node
// FIRST statement: a throw rolls back ALL dictionary changes, // so the bridge must exist before anything can break. dictionary.bridge = { mainAgentId: toolOptions.toolContext.agentId, mainSession: toolOptions.agentNode.flowContext.sessionId };
returnToMain.js — the last Script node
const bridge = dictionary.bridge; // NOT toolOptions Agent.setRootAgentPrompt(bridge.mainAgentId, { prompt: '<RESULT>\n' + JSON.stringify(payload) + '\n</RESULT>', sessionID: bridge.mainSession, requestID: Tools.generateUUID() });
The inner agent has no prompt. An Agent node gets no input along its edge — it reads the newest user-role message for its own agent id. For the main agent the platform seeds that; for an inner agent, nobody does. If you don't seed it, it fails for lack of a user message.
seed the inner agent — with the FLOW TOOL's session, not the main one
Agent.setRootAgentPrompt('inner-agent', { prompt: toolOptions.toolContext.options.context, sessionID: flowContext.sessionId, // the flow tool's session, NOT the main agent's requestID: Tools.generateUUID() });
flowContext was the mistake; here it is the right choice — because you are not answering an outer agent, you are driving an inner one. Two different sessions in one file. The Agent node's agentResponse: true + agentResponseProperty option drops its answer straight into the current dictionary — then you read no cross-session conversation at all. In the parallel fan-out that was the decisive simplification: five copies ran the same agent at once, each wrote into its own dictionary, and none touched another's.The model will get the context format wrong. That is not a defect to fix — it is a property to meet.
The model will send Cyrillic where Latin is needed, miss a field, send text where JSON was expected. And because a tool's reply comes back as a new message the agent reads, you have something better than an exception: you can tell it what's wrong. Use one envelope for every tool.
one envelope, three shapes
// success { "ok": true, "temperatureC": 25.2, "conditions": "overcast" } // the model got it wrong — it can fix and retry { "ok": false, "retryable": true, "error": "Current Weather input is invalid.", "correction": "Call again with !*context as JSON: {\"city\":\"Sofia\"}" } // the source is down — no point retrying { "ok": false, "retryable": false, "error": "Weather service unavailable." }
And in the prompt, once for all tools:
ok: true — use the data, don't call the same tool again.ok: false + retryable: true — read the correction, fix it, try once more; if it still fails, explain to the user.ok: false + retryable: false — tell the user the service is unavailable and stop.Twice in one test day, with no prompting from me: Place Lookup couldn't find "Old Town, Plovdiv" → returned retryable → the agent tried "Historic Plovdiv" and got the coordinates. And Book Search couldn't find "Дюн" in Cyrillic → the agent tried "Dune" and found the three books.
The happy path tests itself. Failure doesn't — and that's exactly where the platform is quietest.
An unwired Agent-node Fallback edge. If the model-provider call fails and the error edge isn't wired, the flow continues out the normal Out and the error text is recorded as the agent's "answer". The next node that reads it passes it on as if it were a real result. That is worse than a clean failure — it is a fake success: the calling agent gets the error text and relays it to the user as fact.
An unwired Script-node Error edge. A thrown exception just ends that branch. The tool pushes nothing, the caller gets No tool response, and there is no explanation of why.
a well-wired flow tool
Start → setup → Agent(inner) ──next_1──▶ return(success) ──▶ End
│
└── error (Fallback) ─▶ returnFailure ──▶ End
setup / return / returnFailure — each error → returnFailure → EndreturnFailure reads the identity from the dictionary bridge and pushes a clean failure to the caller: { ok: false, error: "…" }. The habit worth building: for every node that can fail, ask "where does the caller's answer come from if this node dies" — and wire that path. Sometimes the best exit is no branch at all: instead of a Condition to reject bad input, a script can just leave an empty array — and a Parallel Context over an empty collection passes straight to its own exit. Fewer nodes, fewer edges.
A decision tree, in the order to ask the questions — and one measured result that overturns an instinct.
On parallelism — measured, not assumed. Instinct says concurrent is faster. Here is what came out of five questions, each with one quick tool, over three runs:
| Approach | Average time | Tokens (in / out) | Model rounds |
|---|---|---|---|
| Parallel fan-out | ~29s | 11845 / 714 | ~13 |
| Sequential | ~25s | 11123 / 468 | ~6 |
Parallel was about 15% slower and costlier. The cause is the number of model calls: sequentially the main agent does about 6 rounds; in parallel about 13 — three on the main agent plus two on each of the five workers. Concurrency wins only when the work per item outweighs the branching overhead — a multi-round tool loop per item (2–3 calls a question), a slow external API (seconds a request), many more items (10–20+ where sequential time grows linearly), or heavy per-item processing (summarizing a long text, a chain of reasoning).
The battle-tested habits and the limits you design inside.
HTTP 200 does not mean valid data. The most expensive lesson, and it repeats. One API returned HTTP 200 with a body { "success": false, "errors": [...] } after a redirect; a geocoder returned 200 with no results field for an unknown place. Check both the status and the shape.
check status AND shape
const r = await Http.getJson(url); if (!r.ok) { /* HTTP error — system/http returns ok:false, does not throw */ } const hits = r.body && Array.isArray(r.body.results) ? r.body.results : []; if (hits.length === 0) { /* 200, but empty */ }
Two failure behaviors follow from that: a bad HTTP status (404, 500) does not throw — it returns { ok: false, status }, so check it explicitly; a transport, timeout, blocked address, or oversized response throws, so wrap it in try/catch and wire the Error edge.
Make external requests one at a time. Outbound system/http calls and system/agent reads share one buffer of about 10 MB. Two at once fails; a single response larger than the buffer fails. await each in turn — never fire them together.
one at a time
// NO await Promise.all([Http.getJson(a), Http.getJson(b)]); // YES const first = await Http.getJson(a); const second = await Http.getJson(b);
The budgets you design inside — the same numbers as the Reliability page:
limit and per_page on requests and process in chunks.dictionary · sessionWhat the sandbox allows. Only system/* modules and relative imports of your own files — no npm packages, no fetch (use system/http), no crypto (use Tools.generateUUID()). A tool file needs a named exec export; a helper module you only import needs none. After any contract change, test in a new chat — the old transcript still holds the previous turns, including a stale No tool response, and you'll think the fix didn't take. And in production the system/log record (error / warning / info / debug) is your only window — write which branch was taken, every external call with its status, and every caught error. Now, not later.
Read the behavior you see; the cause is almost always the same one.
| What you see | Almost always |
|---|---|
No tool response, but the logs show the tool ran | Missing or wrong sessionID on the push — check the capital letter; sessionId: is ignored silently. |
| The requested tool does not exist | The name doesn't match, or the project wasn't re-deployed — the registry compiles at deploy. |
| The agent never calls the tool | The catalogue is missing from the system prompt, or "Use tools" is off on the Agent node. |
| The agent shows raw protocol text | The markers aren't at the start of a line, or the prompt didn't tell it to hide them. |
| Two tools requested, one ran | Only the first block runs — the prompt must ask for one call per turn. |
| The agent loops the same tool | The prompt doesn't say to stop after ok: true. |
| The inner agent has no user message | It wasn't seeded — push a prompt with the flow tool's flowContext.sessionId. |
| The agent goes silent with no error at all | The Start Flow / Start Node in the admin don't match the deployed project exactly — PascalCase, literal. |
| Concurrent reads fail, or an oversized response fails | Simultaneous reads, or a response over ~10 MB — make requests one at a time and keep responses small. |
| The fix doesn't take in the same chat | The old transcript still holds the previous turns — start a new chat. |
return. A returned value only ends the script — the tool pushes a user-role message to reply.toolOptions.agentNode, not the bare flowContext. Read it with a lowercase d, write it with a capital D.Keen Agents 2026
Documentation
Release 15