Docs

Building Tools

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.

What a Tool Really Is

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.

A tool is a turn in the conversation, not a return to the model
There is no "return value" to the model. There is only a conversation someone added a line to. A tool is not a function the agent calls and waits on — it is a correspondent the agent passes a note to, which answers with a fresh reply in the same conversation. Every later confusion — "why doesn't the agent see the result", "why doesn't 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
    virtualEndNode

Notice 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").

The Platform's Half and Yours

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.

The platform does
Recognizes the <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.
You do
Teach the model to emit the block (prompt text) · register the tool in keen-tools.json · write the implementation — and make sure it speaks back · describe what each tool does and when it's used.
The platform does not generate a tool catalogue
The registry stores only { 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.

The Three Things You Write

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" }
  ]
}
A registry change needs a fresh deploy
At runtime the platform reads the compiled project, not the JSON file. If a tool is in 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"}
Write "when to use it", not only the context schema
In one real project an agent got "What time is it in New York?" and called 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 <SYSTEM CALL> Protocol

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>
Exact markers — capitals and one space, both required. The markers and each !* begin at the start of a line, no indent.
!*tool
The only field routing depends on — matched literally against a name in the registry.
!*action · !*context
A human label of intent and the free-text input, both handed to the tool as context. Any other !*whatever is captured and passed along too.

Four behaviors are worth knowing before you write the prompt:

  • The block may be embedded in text. The model may write a sentence before and after it — only the block is the call. In practice the user also sees "One moment, checking…", which is pleasant.
  • Only the first block runs. The platform parses all of them but executes only the first. So the prompt must say it outright: one call per turn. If the model emits two blocks because the user asked two things, the second simply vanishes — no error — and the agent then behaves as if it had called it.
  • An unclosed block is plain text. An opening marker with no closing one — say, from length truncation — does not count as a call.
  • !*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.

Script Tool or Flow Tool

The two look identical from the outside — the model emits the same block. Inside they are different things.

 Script toolFlow tool
type"script""flow"
entrytools/weather.jsMultiAnswer-Start
The implementation isone JS file with an execa whole graph of nodes
Can it call an LLMnot directlyyes — with an Agent node inside
Branchingonly with if in codeCondition, Loop, parallel nodes
Reply complexitylow — one sessionhigh — 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.

"I need a second agent" is not automatically a flow-tool reason
A script tool can drive agents too — it has 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.

How the Answer Returns

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:

DetailCorrectSilent mistake
Capitals on the writesessionID: · requestID:sessionId: — silently ignored
Lowercase on the readflowContext.sessionId
Where the session comes fromtoolOptions.agentNode.flowContext.sessionIdflowContext.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 };
};
Make one reply helper and never push anywhere else
In one real tool there were seven exits — invalid input, city not found, two HTTP errors, two transport errors, one success — and all of them went through 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.

The Session Map

Two sessions for a script tool don't tangle easily; three for a nested flow tool almost certainly will without the map.

The main agent (the caller)
Taken from toolOptions.agentNode.flowContext.sessionId — only in the first node. Used for the final answer back to it.
The flow tool = the inner agent
The bare global flowContext.sessionId. Used to seed a prompt for the inner agent.
An inner script tool
Its own toolOptions.agentNode… = the inner agent. Used to answer the inner agent.
toolOptions does not survive the nested tool cycle
When the inner agent calls its own tool, the platform re-runs it and overwrites the { 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()
});
This is the opposite of the return rule — on purpose
In "How the Answer Returns" the bare 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.

Self-Correcting Tools

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.

Cap it at one retry
Without "at most one more attempt" the model can loop forever — and every round costs money. Note that call-level failures — an unrecognized block, a missing tool, a tool that pushed nothing — do not go through the Agent node's Fallback edge; the platform just hands the agent a short message and re-runs it to recover. Fallback fires only on a genuine model-provider failure.

When Things Fail

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 → End

returnFailure 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.

Which Approach When

A decision tree, in the order to ask the questions — and one measured result that overturns an instinct.

  1. Is the work one function — call, transform, return? Script tool. Stop here.
  2. Do you need judgement inside — a second LLM? Flow tool with an Agent node inside.
  3. Do you need branching, waiting, a loop over a collection? Flow tool — the graph expresses that, a file doesn't.
  4. Do you need parallelism over a dynamic count of items? Parallel Context inside a flow tool.
  5. Do you need N fixed, distinct flows at once? Parallel Flows + Flow Pointers.

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:

ApproachAverage timeTokens (in / out)Model rounds
Parallel fan-out~29s11845 / 714~13
Sequential~25s11123 / 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).

Measure before you complicate
A parallel construction that doesn't save time is just more code to maintain — and a bigger bill.

Practices & Budgets

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:

Script-node memory
About 40 MB (≈30 MB for your code plus ≈10 MB for the shared buffer). No "make it bigger" switch — set limit and per_page on requests and process in chunks.
dictionary · session
About 1.5 MB each, with a 60 s lifetime. Working data for one run — not a place to park bulk payloads.
One stored conversation turn
Up to 8 MB per stored agent message or reply — trim an oversized turn.
Return value
Must be JSON-cloneable — plain objects, arrays, and values, no functions or class instances.

What 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.

check status AND shapeone request at a timenamed exec exporttest in a new chatlog every branch

Symptom → Cause

Read the behavior you see; the cause is almost always the same one.

What you seeAlmost always
No tool response, but the logs show the tool ranMissing or wrong sessionID on the push — check the capital letter; sessionId: is ignored silently.
The requested tool does not existThe name doesn't match, or the project wasn't re-deployed — the registry compiles at deploy.
The agent never calls the toolThe catalogue is missing from the system prompt, or "Use tools" is off on the Agent node.
The agent shows raw protocol textThe markers aren't at the start of a line, or the prompt didn't tell it to hide them.
Two tools requested, one ranOnly the first block runs — the prompt must ask for one call per turn.
The agent loops the same toolThe prompt doesn't say to stop after ok: true.
The inner agent has no user messageIt wasn't seeded — push a prompt with the flow tool's flowContext.sessionId.
The agent goes silent with no error at allThe Start Flow / Start Node in the admin don't match the deployed project exactly — PascalCase, literal.
Concurrent reads fail, or an oversized response failsSimultaneous 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 chatThe old transcript still holds the previous turns — start a new chat.

Three Things to Remember

  • The answer travels through the conversation, not through return. A returned value only ends the script — the tool pushes a user-role message to reply.
  • The session stamp comes from toolOptions.agentNode, not the bare flowContext. Read it with a lowercase d, write it with a capital D.
  • You write the prompt catalogue — the platform doesn't generate it. No description in the system prompt means the agent never calls anything.

Script Nodes & Scripts

The exec shape, system/agent, and the sandbox a tool script runs in.

Node Reference

Every node — Agent, Script, Parallel Context, and the rest.

Debugging

Trace a tool call node by node when the reply goes missing.

Reliability

The budgets and guarantees your tools run inside.

Previous

Script Nodes & Scripts

Next

Deployment

Keen Agents 2026

Documentation

Release 15