Agent Script Steps
A Script step runs a Python script from one of the agent’s attached skills. No model is involved unless the script asks for one: the step runs the script in the agent’s sandbox, reads the result the script declares, and moves the run on. Use it for work that should happen the same way every time, such as syncing records, transforming a webhook payload, or calling an integration in a loop.
Add a Script Step
Section titled “Add a Script Step”-
In the agent builder, select + under Agent Steps, then Add Script step.
-
Pick the Skill. The list shows the skills attached to this agent. If you can edit this agent, the skills you created appear too, or every skill in your organization if you are an administrator, and choosing one attaches it when you save.
-
Pick the Script: a
.pyfile inside the skill package. The editor previews the file when you can view the skill directly; attaching a skill to the agent does not grant that access. -
Set the Timeout and the Agent follow-up toggle, then save.
| Setting | What it does |
|---|---|
| Skill | The attached skill the script comes from. The step runs the skill’s latest version, and the run records the exact content that ran. |
| Script | A relative .py path inside the skill package. Python is the only supported language. |
| Tools | Read-only. Scripts run with the tool servers enabled in the agent’s Tools section. |
| Timeout (seconds) | Wall-clock limit for the script, from 1 to 10,800 seconds (3 hours). Defaults to 120. |
| Agent follow-up | Lets the script hand its result to the agent for a model turn. Off by default. See Agent follow-up. |
A skill that a Script step runs cannot be detached from the agent until the step is removed or pointed at another skill. The agent’s organization needs a default chat model even with Agent follow-up off; without one the step fails before the script starts. A Script step never retries: scripts can call tools with side effects, so a step that fails is reported, not run again. Someone who can’t attach the skill can’t duplicate the agent; see What an Attached Skill Reaches.
Structure an Agent Around Script Steps
Section titled “Structure an Agent Around Script Steps”Attaching a skill so a Script step can run its script also puts the skill in front of every LLM step of the agent. An LLM step in Actions mode that runs before the Script steps can open the skill and do its work itself, scripts included. To keep the work in the Script steps:
- Put an LLM step that only collects or formats a value, such as an address or a ticket ID, in Ask mode: set Mode to Ask beside the step’s model. Have its prompt say to reply with the value and nothing else.
- When an earlier LLM step collects the value, have the script read it from
messages, matching that step bystep.number. See The conversation so far. - When a trigger delivers the value, such as in the request body of a Direct Webhook URL trigger, read it from
triggerData. Manual, scheduled, and API runs have no trigger data. See What the run started with. - Decide what a missing value does: declare
errorso the later steps still run, or addhalt_run=Truewhen they cannot do anything useful without it. See Halting the run. - Leave Agent follow-up off on these Script steps unless a model should act on the result. The follow-up turn runs in Actions mode and can open the skill like any other LLM step. See Agent follow-up.
- Put an LLM step that only summarizes earlier results in Ask mode too, so it cannot start the work over.
How a Script Runs
Section titled “How a Script Runs”- Kindo runs
python3 <script path>in a fresh working directory inside the run’s sandbox. The skill’s files are staged at their package-relative paths, so a script can import its sibling modules. - Only text files in the skill package are staged. Binary files are skipped.
- The
kindo_scripthelper module is importable from any staged path. Only the Python standard library is guaranteed to be installed. - Network access follows your organization’s Sandbox Network Access setting. See Tool Actions and Permissions.
- A script still running when the timeout expires is killed.
- Files the script writes outside its working directory stay in the run’s sandbox for the rest of the run, so later steps that use the sandbox can read them. See Hand Files to a Later Step.
Read the Input
Section titled “Read the Input”import kindo_script
run_input = kindo_script.read_input()trigger_data = run_input["triggerData"]messages = run_input["messages"]conversation_id = run_input["conversationId"]read_input() returns the run’s input as a dictionary, or None when it is unavailable. It holds what the run started with under triggerData, the run’s conversation so far under messages, and the ID of the run’s conversation under conversationId. Ignore keys you do not recognize; new ones may be added.
conversationId is the same for every step of a run and different for each run. It contains only letters, digits, -, and _, so a script can use it as a folder name. It is not the run ID: the Conversations and Responses APIs return it with the conv_ prefix.
What the run started with
Section titled “What the run started with”| How the run started | triggerData |
|---|---|
| Integration Event trigger | The event body Kindo received. |
| Direct Webhook URL trigger | The request body. |
| Started by a parent agent, or from a chat | The text the parent forwarded, as a string. |
| Run manually, on a schedule, or through the API | null. Input values sent through the API are agent inputs, not the trigger data. |
payload holds the same value as triggerData and is deprecated. Read triggerData.
The conversation so far
Section titled “The conversation so far”messages lists the run’s messages, oldest first. Each agent step adds a user message describing the step and an assistant message holding its result, so the output of an earlier step is in an assistant message. The first step’s user message begins with the run’s trigger or parent context, then the step’s own text.
| Key | Type | Meaning |
|---|---|---|
id | string | The message’s identifier. The Conversations API returns it with the msg_ prefix as the id of the message’s first item; later items of an assistant message add a numbered suffix, and tool calls are keyed by call_id. |
index | integer | The message’s position in the conversation, starting at 0. |
role | string | "user" or "assistant". |
createdAt | string | When the message was recorded, in ISO 8601. |
step | object or null | The step the message belongs to, as {"number": 1, "name": "Fetch incidents"}. number is an integer and name is a string or null. The value is null for a message outside any step, such as one sent in the run’s chat after its steps finished, or every message of a conversation that is not an agent run. |
forgotten | boolean | true when the agent’s context management has summarized this message out of the model’s view. The message is included in the messages list; the summary that replaced it is not included. |
parts | array of objects | What the message holds, in order. See the table below. |
truncated | boolean | Present, as true, when parts were dropped to fit the message’s size limit. Parts are dropped from the start of the message, so the ones it produced last are kept. |
Each part carries type and the keys for its kind:
| Key | Type | Meaning |
|---|---|---|
type | string | "text", "reasoning", "tool-<name>" for a built-in tool, "dynamic-tool" for a tool from a connected tool server, or another kind. A tool part is one that has toolCallId; a part of any other kind carries only its type. |
text | string | The text of a text or reasoning part. |
toolCallId | string | Tool parts. Identifies the call. |
toolName | string | dynamic-tool parts. The tool’s name. |
state | string | Tool parts. Where the call got to, such as "output-available" or "output-error". |
input | JSON value | Tool parts. What the tool was called with. |
output | JSON value | Tool parts. The result, present when the call succeeded. Check its type before indexing into it: the agent’s context management can replace an older call’s output with a placeholder string, and such a part is marked truncated. |
errorText | string | Tool parts. The error, present when the call failed. |
truncated | boolean | Present, as true, when the part was cut to fit its size limit, or when context management replaced its output. A cut tool part keeps every key except input and output, and only the start of its errorText. A cut text part keeps the start of its text. |
A previous LLM step’s answer is the text of the latest assistant message:
assistant_messages = [message for message in messages if message["role"] == "assistant"]previous_answer = "".join( part["text"] for part in assistant_messages[-1]["parts"] if part["type"] == "text")Two size limits keep the input bounded, each approximate by what JSON escaping adds: about 64,000 characters for a message, which also bounds any single part, and about 128,000 for messages as a whole. A part over the message limit is cut as its truncated row describes. A message over its limit first cuts the input and output of its largest tool parts, largest first, marking each one truncated, so every call stays listed with its state; only if that is not enough does it drop parts from its start. A message always keeps its last part, cut to fit if it must be. When the conversation outgrows the limit for messages as a whole, it holds the newest messages that fit and leaves out the older ones. messageCount is how many messages with content the conversation holds, including any left out here, so a messageCount larger than the length of messages means older messages were left out. A message’s position is its index, not its place in messages.
Hand Files to a Later Step
Section titled “Hand Files to a Later Step”Script steps take no arguments, so a step hands data to a later script step through a file. Write it into a folder named after the run’s conversationId, in the sandbox workspace two levels above the working directory:
from pathlib import Path
import kindo_script
run_input = kindo_script.read_input()if run_input is None: kindo_script.declare_result("error", detail="run input unavailable", halt_run=True) raise SystemExit(0)run_dir = Path.cwd().parents[1] / "runs" / run_input["conversationId"]run_dir.mkdir(parents=True, exist_ok=True)Do not use a fixed path. An agent with Persistent Workspace turned on keeps its sandbox’s files across runs, and overlapping runs by the same user share that sandbox. Runs started by a schedule, a webhook, or an integration event all run as the agent’s creator. See Memory and Persistence. A fixed path can therefore hand one run another run’s data.
Declare the Result
Section titled “Declare the Result”import kindo_script
kindo_script.declare_result("success", detail="Synced 12 records.")Call declare_result before the script exits. The last call wins. A script that exits with code 0 without declaring anything is a success.
| Argument | Type | Meaning |
|---|---|---|
status | "success" or "error" | The outcome. A declared error completes the step; it is an outcome, not a failure. |
detail | string | Free text describing the outcome. Shown in the run and passed to the agent on follow-up. |
guidance | string, optional | Steering for the agent’s follow-up turn. |
infer | boolean, default False | Ask the agent to follow up on the result. Honored only when Agent follow-up is on. |
halt_run | boolean, default False | Cancel the run once this step completes. Later steps never run. |
declare_result raises ValueError for a status other than success or error, and TypeError when infer or halt_run is not a boolean.
Detail and guidance limits
Section titled “Detail and guidance limits”Kindo keeps the last 8,192 characters of detail and of guidance. A longer value loses its beginning, and a leading … marks that this happened. Only the truncated text is stored. Write a summary, or a pointer to output stored elsewhere, rather than a full log. When a note about unrecorded tool calls is added (see What the Run Records), it counts toward this limit and the declared detail is shortened to fit.
How Kindo reads an exit
Section titled “How Kindo reads an exit”| What the script did | Status | Detail |
|---|---|---|
| Declared a valid result, with any exit code | As declared | As declared |
| Declared nothing and exited 0 | success | stdout |
| Declared nothing and exited non-zero | error | stderr, then stdout, then “Script failed without output.” |
| Wrote a result Kindo could not parse | error | stderr, then stdout, then “Script failed without output.” |
| Was killed at the timeout | error | ”Script exceeded its time limit.” followed by the end of stderr or stdout |
A timeout is an error whatever the script declared. Undeclared infer and halt_run are False. Write diagnostics to stderr: it is what becomes the detail when a script fails without declaring one, and a Python traceback lands there on its own.
Agent follow-up
Section titled “Agent follow-up”With Agent follow-up on, a script that declares infer=True hands its result to the agent. The agent receives the declared status, detail, and guidance, along with the tool calls the run recorded, and runs a model turn with the agent’s tools on your organization’s default chat model. The model’s reply becomes the step’s response. With the toggle off, infer=True is ignored and the step completes with the declared result.
kindo_script.declare_result( "error", infer=True, detail="Upstream returned 404 for 3 of 12 records.", guidance="Retry the failed records with the fallback endpoint.",)Halting the run
Section titled “Halting the run”A script that declares halt_run=True completes its step and then cancels the run. Steps after it never run, and the run shows a Run halted marker. Declared together with infer=True, the follow-up turn runs first, then the run halts.
For objective evaluation, a Script step’s verdict follows its status: success is satisfied and error is not satisfied. Because halt_run cancels the run, a run halted by a script reports an aggregate result of partial on the Agents API, with the per-step verdicts intact.
Call Tools from a Script
Section titled “Call Tools from a Script”Scripts use the sandbox tool channel, the same one shell commands use: the kindo_tool module and the kindo-tool CLI, with the same stderr rule, the same ceiling of 1,000 calls, counted per step run, and the same rule that only tools set to run automatically are callable. Script steps always have the channel and need no separate enrollment in its rollout. Four things differ for Script steps:
- The tool set comes from the tool servers enabled in the agent’s Tools section rather than from the conversation.
- Platform tools are callable when the agent enables the Platform server. A shell command cannot call them.
- The channel stays open for the whole script run, bounded by the step’s timeout rather than by a single command’s.
- Each call is recorded in the run as its own entry beside the script’s execution, up to a size limit described in What the Run Records. Every call is recorded in the audit log.
import kindo_tool
issues = kindo_tool.call_tool("linear_list_issues", {"teamId": "ENG"})What the Run Records
Section titled “What the Run Records”Each Script step adds two messages to the run’s conversation: a message naming the skill and script, and a response holding the execution. The execution shows the declared fields under RESULT and the free text under DETAIL. Each tool call the script made appears alongside it, with a preview in place of a large result.
Recorded tool calls share a size limit. Once a script’s calls reach it, later calls are left out of the run, and DETAIL ends with a note such as “130 of 230 tool calls made by this script were not recorded in the conversation because the recorded output reached its size limit; the script received every result.” The script still receives every result, and the audit log records every call.
A run stopped while a script is running kills the script. The response is marked Generation Stopped, and the execution reads “Tool execution was interrupted before completion”.
