Skip to main content

Tooling

Tools let an agent call a registered capability mid-conversation — fetch a timestamp, look up a record, send an email, anything you've registered as a tool. The agent decides when a tool is needed, emits a structured text block in its response, and the engine parses, executes, and feeds the result (or an error) back.

This guide covers:

  • The two kinds of tools Keen supports (script and flow)
  • How tools are registered in your project
  • The system prompt an agent needs to know about its tools
  • The wire format the agent emits to call a tool
  • The retry semantic (attempts)
  • How the engine surfaces failures back to the agent (error-as-prompt-recall)

Two kinds of tools

Every tool registered in your project has a type. The type controls two things: where the tool's implementation lives, and which wire-format variant the agent uses to call it.

TypeImplementationAgent calls it with
scriptA JS file under src/scripts/tools/!*action = use tool
flowA flow entry, called like Run Flow!*action = use tool flow

So when you describe a tool to the agent in its system prompt, say which kind it is — that's how the agent knows which format to emit.

Unified dispatch. Internally both tool kinds dispatch the same way: when the agent emits a <SYSTEM CALL>, the LLM engine spawns the tool as a child flow and defers a re-run of the agent until the tool finishes. The only difference between the two kinds is the start node — a flow tool runs from a real start node in your project, while a script tool runs as a synthesized virtualScriptNode (a one-node child flow built inline from the tool's entry path). Either way the agent is recalled with whatever the tool produced. You don't author this plumbing — you just register the tool and describe it.


Registering tools (the project side)

Tools are registered as a JSON array in your project's tool registry file, keen-tools.json. Each entry:

{
"name": "ToolTimestamp",
"type": "flow",
"entry": "Next-ToolCall",
"attempts": 5
}
FieldPurpose
nameThe label the agent uses in !*tool. Must match exactly (case-sensitive) what you put in the prompt.
type"script" or "flow". See Two kinds of tools.
entryFor type: "script" — the JS file path of the script tool, e.g. "tools/test-tool.js". It must start with tools/ and must not contain .. — script tools live under the project's tools/ directory and the path is validated before dispatch (see Script-tool path validation). For type: "flow" — the <Controller>-<StartNode> entry, e.g. "Next-ToolCall".
attemptsOptional. A signal the agent is told about in its prompt — see Retry / attempts.

Script-tool path validation

For a type: "script" tool, the entry path is validated on both sides of the dispatch (the LLM engine before it dispatches, and the script engine when it runs). A path is accepted only when all of these hold:

  • it is a non-empty string,
  • it starts with tools/, and
  • it does not contain ...

A path that fails validation does not throw — the engine routes a general "no tool response" failure back to the agent (see Error handling — error-as-prompt-recall). So keep script-tool entries inside tools/ with no parent-directory segments.

Example registration

[
{
"name": "Test Tool",
"type": "script",
"entry": "tools/test-tool.js"
},
{
"name": "Tool Agent",
"type": "flow",
"entry": "Tool-Agent"
},
{
"name": "Set Question",
"type": "script",
"entry": "tools/set-question-tool.js"
},
{
"name": "ToolTimestamp",
"type": "flow",
"entry": "Next-ToolCall",
"attempts": 5
},
{
"name": "ToolTimestampScript",
"type": "script",
"entry": "tools/re-tool-call-test-script.js",
"attempts": 20
}
]

The name and the entry are independent — name is what the agent says, entry is what the engine runs. You can call the same script "ScriptA" or "FetchUserCount"; only the registered name matters to the agent.


The wire format the agent emits

Two variants, picked by tool type.

For script tools — use tool

<SYSTEM CALL>
!*action
use tool
!*tool
ToolTimestampScript
!*context
</SYSTEM CALL>

For flow tools — use tool flow

<SYSTEM CALL>
!*action
use tool flow
!*tool
ToolTimestamp
!*context
</SYSTEM CALL>

Parameters

ParameterValueNotes
!*actionuse tool (script) or use tool flow (flow)One literal phrase per tool type.
!*toolThe tool's registered nameCase-sensitive exact match against the registry.
!*contextFree-form textWhatever the agent wants to pass in — instructions, parameters, JSON, or natural language. May be empty for tools that take no input.

Strict formatting rules

The engine's parser depends on these. Non-negotiable:

  • <SYSTEM CALL> and </SYSTEM CALL> must be at the start of a line. No indentation. No text on the same line.
  • Each !* marker must be at the start of a line — same rule.
  • Do not indent the markers under bullets, code fences, or anything else.
  • No extra text inside the block beyond the four markers and their values.
  • Outside the block, the agent can write whatever it wants — narration, follow-up. The engine only acts on what's between the <SYSTEM CALL> markers.

Common mistakes

  • Wrapping the block in triple backticks (```).
  • Adding commentary like "I will use ToolTimestamp:" before !*tool.
  • Indenting markers as if they were a bulleted list item.
  • Putting two tool calls in a single <SYSTEM CALL> block. One block = one tool call.
  • Using use tool for a flow tool, or use tool flow for a script tool.

The system prompt template

This is the canonical prompt — drop it into your Agent Node's system prompt and customize the tool list at the bottom.

Notice how the prompt shows the agent both wire-format variants so it can pick the right one based on each tool's described type.

You are helpful assistent.

<SYSTEM>
**You will be given a set of tools to use for operation.**

When you need to use the tool, please output it between `<SYSTEM CALL>` and `</SYSTEM CALL>` markers.

Your tools are: ToolTimestamp

Use the following format for tools that are descriped as a pipe:
<SYSTEM CALL>
!*action
use tool flow
!*tool
ToolTimestamp
!*context
</SYSTEM CALL>


Use the following format for tools that are descriped as a script:
<SYSTEM CALL>
!*action
use tool
!*tool
ToolTimestamp
!*context
</SYSTEM CALL>

**Important:**

- Place `<SYSTEM CALL>` and `</SYSTEM CALL>` at the **start of the line**.
- Each `!*` marker must be at the **start of the line**.
- The content for each parameter can include any text, including special characters.
- **Do not indent** the markers.
- Do not include any extra text inside the `<SYSTEM CALL>` block.
- Outside the `<SYSTEM CALL>` block, you can provide explanations or continue the conversation as needed.

- If the tool returns attempts it means that we can recall it again. If the tool returns attempts 0 it means no need to recall it. Usually attempt can be greater than 0 in case of error.

---

**ERRORS:**

- `<SYSTEM TECHNICAL ERROR>` and `</SYSTEM TECHNICAL ERROR>` means the system throws an error and the tool is unreachable. In most cases we don't need to retry the call.
In this case is better to return appropriate message that the system can't execute the tool.

- `<SYSTEM SCRIPT ERROR>` and `</SYSTEM SCRIPT ERROR>` means we tried to execute a script tool but it throws an error. In this case we receive an error message. We have to check it.
Sometimes the error could be due to inappropriate tool input parameters. In this case retry the call else just inform with appropriate message.

- `<SYSTEM FLOW ERROR>` and `</SYSTEM FLOW ERROR>` means we tried to execute a flow/pipe tool but it throws an error. In this case we receive an error message. We have to check it.
Sometimes the error could be due to inappropriate tool input parameters but some script is executed inside and throws error. Check the error. In this case retry the call else just inform with appropriate message.

**Here is the available tool:**

### 1. ToolTimestamp
- **Description**: This tool is a pipe. This tools returns the current timestamp in json form and more specifically in result key.
- accepts context


### 2. ToolTimestampScript
- **Description**: This tool is a script. This tools returns the current timestamp in json form and more specifically in result key.
- accepts context

What to customize

  1. Your tools are: line — list every tool's name (comma-separated).
  2. The numbered tool sections at the bottom — one per tool. Always say "This tool is a pipe." or "This tool is a script." in the description so the agent picks the right wire format. Add a one-line purpose statement and (optional) "accepts context" or a context-shape hint.

How tool results come back

A tool — whether script or flow — runs in its own isolated dictionary scope. It has no direct access to the calling agent's dictionary. The tool's result reaches the agent as a new user prompt in the agent's next turn, and the agent re-cycles to react to it.

Note on the <SYSTEM RESPONSE> wrapper. The engine reference confirms the mechanism (the tool result is set as a new user prompt and the agent re-cycles), but the exact <SYSTEM RESPONSE>…</SYSTEM RESPONSE> marker wrapping shown below is observed behavior, not specified in the current engine reference. Treat the markers as illustrative and verify the exact wrapping against your install.

The contract

Inside the tool, set dictionary.result to the text you want the agent to see:

// inside a script tool's exec
dictionary.result = 'The current timestamp is 2026-05-07T10:42:13Z';

When the tool finishes, the engine:

  1. Reads dictionary.result from the tool's final dictionary.
  2. Wraps it as <SYSTEM RESPONSE>${dictionary.result}</SYSTEM RESPONSE>.
  3. Pushes that block as a new user-role prompt for the calling agent (via setAgentPrompt).
  4. Recalls the agent — the agent runs again with this new prompt folded into the conversation.
  5. The agent's next response can be either a regular answer (final reply to the user) or another <SYSTEM CALL> block to invoke a second tool. See Retry / attempts for the recursion cap.

So the agent sees, on its next turn:

<SYSTEM RESPONSE>
The current timestamp is 2026-05-07T10:42:13Z
</SYSTEM RESPONSE>

Self-reporting a logical failure

If the tool ran successfully (no thrown error) but the operation didn't logically succeed — sensor unreachable, validation rejected, partial result — a common convention is to set both fields:

dictionary.result = 'Could not connect to sensor 300';
dictionary.toolStatus = 'error';

Verify against your install. dictionary.toolStatus does not appear in the current engine reference, and the <SYSTEM … ERROR> markers are confirmed there only as things the agent prompt is taught to recognize — not as a documented engine-side wrapper keyed off toolStatus. Treat the toolStatus = 'error'<SYSTEM SCRIPT ERROR> mapping below as a convention to confirm against your deployment rather than a guaranteed engine contract.

The intent is that the agent sees this as a failure (rather than a plain result) and applies the same retry-vs-inform logic as a thrown error. See Error handling below.

Soft validation — coaching the agent to retry with corrected inputs

A common case: the tool ran successfully, didn't throw, but the agent passed wrong or incomplete input (forgot a parameter, sent a malformed value, used the wrong sensor ID, etc.).

You don't need to throw an error or set toolStatus = 'error' to handle this. Just write a clear correction message into dictionary.result:

// inside a script tool's exec
if (!props.sensorID) {
dictionary.result = 'You did not provide a sensorID. Please call again with !*context containing sensorID=<number>.';
return;
}

if (!isValidSensor(props.sensorID)) {
dictionary.result = `Sensor ${props.sensorID} does not exist. Available sensors: 100, 200, 300. Please call again with one of these.`;
return;
}

// Happy path
dictionary.result = `Sensor ${props.sensorID} reads ${getSensorReading(props.sensorID)} degrees.`;

The agent receives the correction as a normal <SYSTEM RESPONSE> block:

<SYSTEM RESPONSE>
You did not provide a sensorID. Please call again with !*context containing sensorID=<number>.
</SYSTEM RESPONSE>

The LLM reads this, understands its mistake, and emits a new <SYSTEM CALL> block with corrected !*context on its next turn. The tool runs again, gets valid inputs, returns the real result. Agent then writes the final answer.

Why this works

The agent isn't pattern-matching on error markers — it's reading the text in the SYSTEM RESPONSE and reasoning about it. A clear, actionable message naturally produces a corrected retry. So this pattern leans on the LLM's general intelligence rather than any engine-level retry mechanism.

When to use which path

SituationTool sets…Agent seesCounts toward retry cap?
Inputs valid, operation succeededdictionary.result = '<result>'<SYSTEM RESPONSE>No
Inputs WRONG, operation didn't run / didn't matterdictionary.result = '<correction message>'<SYSTEM RESPONSE>Yes (agent will recall the tool, which counts)
Inputs OK, but the operation logically failed (e.g. external service down)dictionary.result = '<failure note>' + dictionary.toolStatus = 'error'<SYSTEM SCRIPT ERROR>Yes
Tool itself crashed (threw an exception)(engine handles)A 'No tool response' recovery recall — the engine soft-fails the tool and re-prompts the agentYes

Soft validation sits in row 2 — the tool worked fine, the inputs need fixing. Use it when the agent CAN reasonably correct itself given a hint. Use toolStatus = 'error' (row 3) when nothing the agent can change will help the next call succeed.

Tip — write the correction like an instruction, not a complaint

Bad:

"Missing sensorID."

Better:

"You did not provide a sensorID. Please call again with !*context containing sensorID=<number from the user's question>."

The more the message looks like a directive the agent can follow, the more reliably it'll produce a clean retry on the very next turn — saving you a few iterations against the retry cap.

Fallback when dictionary.result is unset

If you don't set dictionary.result, the exact behavior is engine-version-dependent and should not be relied on. The path the current engine reference confirms is a recovery recall: when a tool runs but pushes no response prompt, the engine recalls the agent with a 'No tool response' recovery prompt rather than auto-serializing the result object. Best practice: always set dictionary.result explicitly — that's the clean, version-independent contract.

Tool author's checklist

  • Set dictionary.result to a string the agent can quote, summarize, or act on.
  • If the operation failed, also set dictionary.toolStatus = 'error'.
  • Keep the response short — it's added to the agent's prompt next turn, which costs tokens.
  • Match the description you registered the tool with (e.g. "returns the timestamp as text" → set dictionary.result = '<timestamp>').

What the user sees while a tool runs

While the engine dispatches a tool, the user sees a brief progress indicator in the chat (a "thinking" state naming the tool being called), followed by another while the agent re-runs to incorporate the result. The exact wording is a product-UI detail and may change.

Note on .result vs .response

The magic key a tool sets to return its answer to the agent is dictionary.result (a flow tool's result is conventionally in dictionary.result, and an Agent Node answer routed with __callContext = 'tool' lands there too). Some older notes called this dictionary.response; that name is not the contract — under fan-out (parallelContext) it caused agents to collide on a shared key. Always write the tool's reply to dictionary.result.


Retry / attempts

The agent's tool-using cycle is a recall loop — the agent emits a <SYSTEM CALL>, the tool runs, its output (or a failure message) is pushed back as a new user-role prompt, and the agent is recalled. The agent may then answer the user or emit another <SYSTEM CALL>. The loop continues until the agent stops calling tools.

The attempts signal

attempts is a number the agent is told about in its prompt — it is a hint to the model, not a hard counter the agent reads from anywhere. The agent's system prompt teaches the contract:

"If the tool returns attempts it means that we can recall it again. If the tool returns attempts 0 it means no need to recall it. Usually attempt can be greater than 0 in case of error."

So attempts shapes how willing the agent is to retry a failing call:

Failure shapeSuggested attempts
Pure logic bug (won't change on retry)0 (one-shot — don't recall)
Network blip / transient outage3–5
LLM emitted bad !*context formatting (it can self-correct)15–20
Documentation pending — loop cap

An engine-enforced upper bound on the recall loop (a numeric default and the registration attempts field as a hard override) could not be confirmed against the current engine source. Treat attempts as the prompt-level signal described above. The exact cap mechanics are documentation-pending — verify against your install before relying on a specific number.

Behaviour observation — one tool call per turn

The engine acts on a single <SYSTEM CALL> block per turn — it parses the call, dispatches the one tool, and recalls the agent with the result. Plan agent prompts for one tool call per turn; if the agent needs two tools, let it call one, get the result, then emit another call in its next turn.


Error handling — error-as-prompt-recall

When a tool can't run or fails, the engine does not branch into a separate error channel. It routes the failure through the same recall loop as a normal tool result: a short, general failure message is pushed back to the agent as a new user-role prompt, and the agent is recalled. The agent reads the message and decides whether to retry with corrected input or to tell the user the tool couldn't run.

This unified behavior covers every tool-failure case:

  • the <SYSTEM CALL> block couldn't be parsed,
  • the named tool isn't in the registry,
  • a flow tool's entry is missing,
  • a script tool's path is invalid (see Script-tool path validation),
  • the tool ran but produced no response,
  • the tool threw mid-execution.

The failure message is intentionally general

The text pushed back to the agent is a single general sentence — it never quotes the user's values, lists "valid options," or names specific project state. Two reasons: it keeps project state out of the model's context, and it avoids stale specifics drifting as the project changes. The model is left to reason about the retry from a general signal; specific diagnostics stay in your server-side logs.

This is why soft validation (writing a clear correction into dictionary.result) is the right tool when you want the agent to self-correct with specifics — your message, your wording. See Soft validation.

The agent still learns the error markers

The agent's system prompt teaches it the <SYSTEM … ERROR> markers (<SYSTEM TECHNICAL ERROR>, <SYSTEM SCRIPT ERROR>, <SYSTEM FLOW ERROR>) so the model knows a tool can fail and how to react:

Marker the prompt teachesMeaningHow the agent should react
<SYSTEM TECHNICAL ERROR>the system couldn't reach the toolusually don't retry — tell the user the tool can't run right now
<SYSTEM SCRIPT ERROR>a script tool threwretry with corrected !*context if it looks like bad input, else inform
<SYSTEM FLOW ERROR>a flow tool threw inside a sub-nodesame as Script Error — judge parameter issue vs deeper failure

Keep the ERRORS section in every agent prompt that uses tools — it's what shapes the agent's reaction when a recall carries a failure.

The one exception — an LLM-provider error is not recalled

If the LLM provider itself errors — bad credentials, provider outage, content filter, oversize response — the engine does not recall the agent. The failure becomes the agent's own response, classified with responseType: 'error'. A recall would just hit the same provider error in a loop, so the agent's turn ends there. Wire what happens after an error response in your flow (e.g. a conditionNode branching on responseType).


Multiple tools

To expose more than one tool, list every tool name in the Your tools are: line and add a numbered section per tool at the bottom — explicitly stating script vs pipe in each description:

Your tools are: ToolTimestamp, ToolTimestampScript, ToolEmailSend
**Here are the available tools:**

### 1. ToolTimestamp
- **Description**: This tool is a pipe. Returns the current timestamp as text in `dictionary.result`.

### 2. ToolTimestampScript
- **Description**: This tool is a script. Returns the current timestamp as text in `dictionary.result`.

### 3. ToolEmailSend
- **Description**: This tool is a script. Sends an email. Pass `to=...; subject=...; body=...` in `!*context`.

The agent picks which tool fits the user's request, then emits one <SYSTEM CALL> block calling that one tool — using the format that matches the chosen tool's type.


Best practices

  1. One tool per <SYSTEM CALL> block. Atomic. The engine processes only the first parsed block per turn.
  2. Always say "This tool is a pipe." or "This tool is a script." in the description. The agent uses that to pick the right wire format.
  3. Keep tool descriptions tight. One sentence: type + purpose + return shape. The LLM doesn't need a manual.
  4. Use !*context for parameters, not prose. If the tool needs structured input, instruct the agent to format !*context as key=value; key=value or JSON. Don't make tool authors parse natural language.
  5. Keep the strict formatting rules visible in every agent prompt that uses tools. Don't trim the "Important" block or the "ERRORS" block.
  6. Validate inputs in your tool and coach the agent on bad calls. Set dictionary.result to a clear directive ("Please call again with sensorID=<n>") instead of throwing. The agent corrects itself on the next turn — see Soft validation.
  7. Distinguish soft validation from real failure. Soft validation = dictionary.result only. Real failure = dictionary.result + dictionary.toolStatus = 'error'. Throw only for unexpected exceptions. The three paths surface to the agent differently and have different retry semantics.
  8. Pick attempts deliberately. Network/format failures benefit from retries; logic bugs don't. If unsure, start at 5.
  9. Start with a no-input tool (like ToolTimestamp) to verify your prompt + the wire format are working end-to-end. Add tools that take input after.
  10. One agent, focused tool set. Don't load every tool into every agent. Smaller tool list → fewer wrong choices, cheaper prompts.

End-to-end example

Registration (project)

{
"name": "ToolTimestamp",
"type": "flow",
"entry": "Next-ToolCall",
"attempts": 5
}

Agent system prompt (excerpt)

Your tools are: ToolTimestamp

Use the following format for tools that are descriped as a pipe:
<SYSTEM CALL>
!*action
use tool flow
!*tool
ToolTimestamp
!*context
</SYSTEM CALL>



### 1. ToolTimestamp
- **Description**: This tool is a pipe. Returns the current timestamp as text in `dictionary.result`.

User asks: "What time is it?"

Agent's response

I'll fetch the current timestamp for you.

<SYSTEM CALL>
!*action
use tool flow
!*tool
ToolTimestamp
!*context
</SYSTEM CALL>

Engine

  1. Parses the block.
  2. Sees !*tool = ToolTimestamp → looks up the registry (keen-tools.json) → type: flow, entry: Next-ToolCall.
  3. Spawns the Next-ToolCall flow as a tool child flow (a flow tool runs from its real start node; a script tool would run as a virtualScriptNode).
  4. The tool sets dictionary.result = "2026-05-07T10:42:13Z".
  5. That response is pushed back to the agent as a new user-role prompt for its next turn.
  6. Agent writes the timestamp into the chat for the user.

If something goes wrong

<SYSTEM FLOW ERROR>
ScriptNode "compute-stamp" threw: ReferenceError: now is not defined
</SYSTEM FLOW ERROR>

The agent reads the message, decides the error is internal (not its !*context), and informs the user it couldn't get the timestamp instead of retrying.


Still TBD

Result flow back is now answered (see How tool results come back). The chain question is answered too — engine processes only the first <SYSTEM CALL> block per turn (see Behaviour observation).

What's still open is just the input delivery side:

  • How is the agent's !*context delivered to the tool? For a script tool — does it arrive as props.context in exec(props)? As a key on the tool's starting dictionary? Both?
  • For a flow tool — where does !*context land at the start of the flow? dictionary.context? On the Start Node's settings somewhere?
  • Is !*context always a plain string, or is it parsed (JSON, key/value) when the tool description hints at a shape?