SOPs are autonomous, multi-step workflows that allow an AI agent to perform complex tasks without human intervention. Unlike simple Cron Jobs, SOPs include planning, tool restrictions, and self-reflection to ensure reliability.
An SOP consists of multiple Steps. Each step defines:
contains:OK or json).retry, abort, or escalate).The GoClaw Admin Dashboard provides a dedicated interface for managing SOPs.
Navigate to the SOPs tab in the sidebar. Each SOP is a card showing its name and ID, its purpose, the number of steps, its owner, and whether it is Active or Paused; badges show the linked cron job and how many steps require approval. Click a card to open the SOP: its screen has Run, pause/enable and delete buttons, and the tabs Overview, Steps, Diagram, Channels, History and Edit.
Click the New SOP button. A blank form opens on the Edit tab.
daily-report-generator).context).default, main or an empty owner is the default agent (unless
an agent is configured under that ID), and so is an owner that names no
configured agent. A run a webhook route starts runs as the
route's agent instead, and one sop_run starts in a conversation as that
conversation's agent, on that agent's model too. A run for an agent the
gateway is not running (removed from config.yaml, added since the gateway
started, or whose model failed to start) does not start: a webhook route is
answered 409, sop_run and Run are refused, and a scheduled run fails
before its first step.0 0 9 * * * is daily at 09:00; a five-field 0 9 * * * is read the
same. Clearing the field leaves the job as it is: pause or delete it on the
Cron page. A schedule that cannot be applied is reported after the save,
which still goes through.timeout, which a save keeps (see below).Validate checks the draft without saving it; Save saves it.
Open the SOP and its Edit tab. The form has the same fields; the next run uses what you save.
What a save keeps. The form has no field for a step's
timeoutor the SOP'sprerequisites,virtual_leveland review dates; a save keeps them as they are.POST /api/sopkeeps any field the request leaves out, at the top level and in each step (matched by itsid); a field it sends, even empty, replaces the stored one. A save that changes the SOP's definition archives the previous version, whichsop_rollbackrestores, and raises the version; pausing or resuming it does not.The agent's
sop_savetool keeps the owner, timezone, SOP-level tools, shared knowledge and prerequisites when an update leaves them out (an empty value clears one), always keepsvirtual_level, and keeps a step'stimeout,queries,queries_shadowandmcp_callwhen the step sends none. It archives the previous version too.
Click Run on the SOP's screen to start a run immediately. This is useful for testing new procedures before scheduling them.
The run goes on in the background, as one started with sop_run: it ends after
2 hours at most, and it waits for a free slot. Runs started with Run,
sop_run or a webhook, and runs resumed after a restart, share 4 slots;
scheduled runs do not take one. A paused SOP, or one overdue for review, is not
run: the page says why. Resume it (Resume on its screen), or ask the agent
to review it (its sop_review tool sets the next review date), first.
Open the History tab to see a detailed log of past runs.
completed (every step passed, or a step's end_run_if ended
the run early with nothing failed: the entry says which step and why; see
Ending a run early, or skipping a step),
partial (the run reached its last step, or ended early, but at least one
step failed), failed, escalated (a step with
failure_mode: escalate ended the run), interrupted (a restart cut the run
off and it was not resumed; see Time limits and restarts),
or running. A run is failed when a step with failure_mode: abort stopped
it, when it could not start because a prerequisite or virtual level check
failed, when one step was reached more than 3 times through
on_success_goto/on_failure_goto jumps, or when an attempt did not stop
after running out of time.3/4).manual (Run, or the agent's sop_run tool), cron, or
webhook:<type> for a run a webhook started: webhook:graylog for a
Graylog alert, webhook:gitlab for a
GitLab event. See Runs started by a webhook.A SOP also runs when an outside system calls the gateway. A webhook route with a
sop_id runs that SOP for the event instead of a chat turn: a
Graylog alert works this way, and so can a
GitLab event or anything else that can POST JSON. Routes are
made on the Webhooks page, in config.yaml,
or, for a Graylog alert, by the agent.
batch_mode: bulk the route gathers its events
for batch_window seconds (default 300) and runs the SOP once for them, at
most 500 events per run.200, queued, and waits for that run. When it
ends, the events that came meanwhile start one run together, every payload
in the order they came, so a person editing an issue field by field (GitLab
sends an event per field) starts two runs rather than one per field, and the
second sees the first one's comment. Queued events survive a restart and run
once the gateway is back. Events about different objects run side by side, as
do a GitLab project's pushes, jobs and deployments, which name no object of
their own. More than 500 events waiting for one object are refused with 503.webhook-system, on the route's agent. That identity is not
privileged: whatever tools a step declares, it cannot manage cron jobs or
SOPs, write system files, or register Graylog alerts. A step that declares
db_write or metrics_store can write to a datasource opened with
allow_write: true, as in any other run: only an admin can save a SOP, so the
step's tool list carries the author's grant. Every step sees the inbound
event, so declare them only where the write is safe whatever the payload
says.409 when the SOP was
deleted or disabled or is overdue for review, 503 when too many webhook-started runs
are already waiting or running. A bulk route answers 200 once the event is
queued. When its run later cannot start, nothing is sent back: the events are
dropped when the SOP refuses to run (deleted, disabled, overdue for review) or
the route was deleted, with the reason noted on the route on the Webhooks page
and in the gateway log. When too many runs are in progress they stay queued for
the next round without a note; after any other error they stay queued and the
error is noted on the route and in the log. A paused route's events wait until it is
enabled again. The full list is in
Gateway Endpoints.Each step in the steps array has the following fields. These are the exact
JSON keys. A key spelled any other way is not an error β it is simply ignored,
and the step runs as if you had never set it. Copy the names from this table
rather than from memory.
| Field | Type | Description |
|---|---|---|
id |
string | Required. Unique ID for the step. |
name |
string | Display name. |
intent |
string | Required, min 20 chars (optional for pure deterministic query steps). One sentence saying why this step exists. |
instruction |
string | Required, min 80 chars (optional for pure deterministic query steps). What the agent should do. Can use {prev_output} for the result of the step immediately before, or {{steps.<id>.output}} / {{steps.<id>.file}} for any earlier step. |
output_format |
string | The shape the answer must take. Required in practice for any step that declares outputs β the Web UI warns when it does not mention JSON. |
constraints |
array | Inline rules the step must obey, injected verbatim into its prompt. |
context |
string | Step-level knowledge (markdown) injected into the step prompt. |
inputs |
object | Hardcoded parameters, e.g. {"project_id": "5"}. |
outputs |
array | Optional. Names this step publishes. Declaring them forces the step to answer with a JSON object keyed by exactly these names. |
queries |
array | Optional. Deterministic saved query bindings ([{template, datasource, params, as, max_rows}]). When queries_shadow is false, the step runs these queries directly through the worker pool with zero LLM calls and publishes <as> (the Markdown table) and <as>_ref (its data_ref ID for spreadsheet). Parameter values may be literals, {{run.dateΒ±Nd}}, {{run.timeΒ±Nh|m}} (evaluated in the SOP's timezone, defaulting to the server's local timezone), or {{steps.<id>.outputs.<name>}}. |
queries_shadow |
bool | Optional. When true, queries run in shadow mode (recording version, row count, duration, and errors in query_runs on the step result) while the step still executes normally through the LLM. |
for_each |
string | Optional. Collection to fan out over β the step runs once per item. Cannot be combined with non-shadow queries. |
item_var |
string | Optional. Placeholder for the current item, default item. |
max_items |
int | Optional. Fan-out cap, default 50, ceiling 500. |
on_item_failure |
string | Optional. abort (default) or continue. |
mcp_call |
object | Optional, with for_each. {server, tool, arguments, timeout}: the step calls that MCP tool once per item itself, with zero LLM calls, and publishes results and results_ref. See Map steps that only call an MCP tool. |
uses_outputs |
array | Optional. Earlier results this step consumes β "s1" for a whole step, "s1.staff_list" for one published field. |
tools |
array | Tool names this step may call. An empty list does not mean "all" β see Which tools a step may call below. |
success_check |
string | Rule to validate output: json, json_fields:a,b, contains:TEXT, not_contains:TEXT (both ignore case), equals:VALUE, min_len:N, max_len:N, regex:PATTERN, or expr:CONDITION, a comparison of published values (see Checking published values). Empty means no check. A rule GoClaw does not recognise (a misspelling such as contain:), a length that is not a number, or a regex that does not compile is refused when the SOP is saved. A SOP saved before that check runs as it did (an unknown rule, or min_len without a number, passes every output), and the gateway logs the rule each time; correct it at the next save. |
failure_mode |
string | What to do when retries are exhausted: retry (the default: carry on to the next step), abort (fail the run and run the rollback), or escalate (end the run escalated, run the rollback, and send a notice to sop.alerts in config.yaml, or only to the gateway log when that is not set). Jumping to another step is a separate field β on_failure_goto, not a value here. |
on_failure_goto |
string | Optional. Step ID to jump to when retries are exhausted. Takes precedence over failure_mode. |
max_retries |
int | How many attempts the step gets in all, the first one included. Default 2 (one retry); 1 means no retry; cap 5. |
timeout |
string | Optional. How long one attempt may run before it is cancelled and retried, e.g. "20m"; between 1m and 2h. Default: sop.step_timeout in config.yaml, 15 minutes when unset. |
on_success_goto |
string | Optional. Jump to a specific step_id instead of the next sequential step (Conditional Branching). |
end_run_if |
string | Optional. One comparison over published values, e.g. "{{steps.s3.outputs.rows}} == 0", checked after the step passes: when it holds, the run ends there, completed, with no model call. See Ending a run early, or skipping a step. |
skip_if |
string | Optional. The same kind of comparison over earlier steps' values, checked before the step starts: when it holds, the step is skipped and the run goes on to the next one. |
requires_approval |
bool | Optional. After the step answers, the run waits (suspended state) until someone approves or rejects the step through the API β see Human-in-the-Loop. Cannot be combined with non-shadow queries. |
rollback_command |
string | Optional. A shell command that undoes this step, run when a later step ends the run with abort or escalate (the newest step's first). It runs only if tools.shell.allow and the deny lists permit it, and each command is stopped after 60 seconds. |
A step calls exactly what the SOP declares for it, and nothing else. Nothing is chosen for it at runtime by relevance ranking.
The effective list is built like this:
tools, unioned with the SOP-level tools (declare
shared tools such as memory_write or notify once at SOP level instead of
repeating them on every step);"*" in either list stands for every registered tool;Six side-effect-free internal tools are available to every step regardless of
what it declares: calc, datetime, json_transform, read_output, regex,
text_utils.
A SOP sends only to its pinned targets. Inside a run,
messageandnotifysend to the conversation pinned for that SOP and channel, whatever target the model names; with no pin for the channel, the call is refused (no pinned discord target for SOP β¦) and the step fails. Pin the target on the SOP's Channels tab, withPOST /api/sop/channels, or by asking the agent in the destination group to runsop_pin_channel.notifytochannel: "webhook"posts to the URL given and needs no pin.
messageandnotifyare not automatic. A step that sends must declare the sending tool itself. A step scoped to, say,["db_query"]that tries tomessageis refused at runtime withtool "message" exists but was not offered for this requestβ the report is built and then never leaves the agent. Putmessage(ornotify) on the step whose job is to send, and only on that step: a metrics step that can also post will eventually post its raw intermediate JSON to your channel.
A send that goes nowhere fails the step (from 0.0.86). A step passes on its own answer, not on whether its tools worked β which is right for a query, since "no rows" is a real answer, and wrong for a send. So delivery is checked separately: if a step called
messageornotifyat all and every one of those calls failed, the step does not pass, whatever it wrote. A step that never tried to send is unaffected, and one that failed onmessagebut got through onnotifyhas delivered and still passes.The verdict is applied once, after the retries, not inside them: a channel that has stopped accepting messages will not start because the step ran again, so a retry would only repeat the queries and the tokens to reach the same rejection.
Every tool error a step hit is recorded on its result as
tool_failures(tool, calls, failures, last error) whether or not the step passed, so a step that quietly got nothing back from a query is visible too.
A retry does not do again what an earlier attempt did. A step that fails its check is retried to correct its answer, not to repeat its work:
- When an earlier attempt's
messageornotifywent out, the retry is offered neither, and is told the message was delivered.- When an earlier attempt's call that changes something went through, the retry is not offered that tool, and is told what was already done. This covers
memory_write, ametrics_storerecord,db_write,file_write,- A tool that reads or writes depending on the call stays on the retry, for its reads, and a write through it is refused. These are
http_request,metrics_store, and the Uptrace, Grafana and Zabbix tools.- A call that failed did not go through, so the retry may make it again.
- Two tools stay available whatever they did.
mcpmay only have read, and a retry may need the data again;db_analyzewrites only its own schema cache.- Every attempt starts a new conversation, so the retry is handed what the earlier ones got, under From earlier attempts: the
data_refof every result they stored (up to 20), whichread_outputreads without fetching the data again, and what each call that went through returned (up to 10, 400 characters each), so that its answer can say what was done.
Tool names must exist in the agent's registry β check /api/tools for the live
list. MCP servers are not tools: reach them through the mcp tool (or
db_query for a datasource), naming the server in the call.
From 0.0.86 the step's prompt is scoped the same way: the workspace TOOLS.md
catalogue is narrowed to the tools that step may call, so it is no longer shown
descriptions of tools it would be refused for using. If your own TOOLS.md does
not use the - **tool_name** β description form, it is passed through whole
instead of being filtered.
A step still publishing several messages is not blocked β a step may have more than one thing to say β but a non-map step that publishes more than once logs a warning, because one report split across posts arrives out of order.
A step does not automatically see what earlier steps produced. {prev_output}
only ever means the step immediately before, so a step that needs results from
two different places used to have no way to get them.
The following fictional work-assignment SOP uses synthetic staff, jobs and categories:
| Step | Produces |
|---|---|
s1 |
The staff rostered on shift today |
s2 |
Pending jobs, grouped by category |
s3 |
Assigns s2's example jobs to s1's staff |
By the time s3 runs, {prev_output} holds only s2. Declare the dependency
instead:
{
"id": "s3",
"name": "Assign work",
"intent": "Distribute pending category jobs across the staff on shift today",
"uses_outputs": ["s1", "s2"],
"instruction": "Use the staff roster from step s1 and the pending job list from step s2. Assign the example jobs, matching each job's category to a staff member rostered for that category.",
"tools": ["db_query", "file_read"],
"output_format": "JSON array of {staff_id, job_id, category}"
}
The executor injects both outputs under an Upstream step outputs heading in
s3's prompt.
Declaring
uses_outputsreplaces the implicit "Previous step output" block. A step that asks for one small field should not also be handed the whole previous answer β that is the cost it was avoiding. List the previous step inuses_outputswhen you want both.
A step publishes a single blob of text by default. Declare outputs and it
publishes addressable fields instead:
{
"id": "s1",
"name": "Shift roster",
"outputs": ["staff_list", "shift_date"],
"output_format": "JSON object {staff_list: [{staff_id, name, category}], shift_date: \"YYYY-MM-DD\"}",
"instruction": "Query the rota for today and return the staff on shift with the category each covers.",
"tools": ["db_query"]
}
The step must then answer with a JSON object carrying exactly those top-level keys. Markdown fences and a short preamble are tolerated; a missing key or unparseable JSON fails the step and feeds the reflection retry with a message naming the offending key β so the model is corrected instead of the SOP quietly continuing with nothing.
Downstream, take one field rather than the whole answer:
{ "id": "s3", "uses_outputs": ["s1.staff_list", "s2.jobs_by_category"] }
Referencing a name the producing step does not declare is rejected at save time, with the real list of names in the error.
For finer control, name a value where you want it to land:
| Reference | Expands to |
|---|---|
{{steps.s1.output}} |
That step's full captured answer, uncapped |
{{steps.s1.file}} |
The artifact path of that answer, for file_read |
{{steps.s1.outputs.staff_list}} |
One published field |
{{steps.s1.outputs.staff_list.file}} |
That field's own artifact path |
{prev_output} |
The previous step only (unchanged) |
Single braces ({steps.s1.output}) work too. A reference that is also declared
in uses_outputs is injected once, not twice. A misspelled selector produces an
explicit marker in the prompt rather than passing through as literal text.
A step that handles a whole collection does it in one LLM call: "assign the pending jobs across every category". That holds until the collection grows. The model must then fit every category into one prompt and write every assignment into one answer β which is where long lists get silently truncated, and where one bad row fails the entire step.
for_each turns that into one call per item:
{
"id": "s3",
"name": "Assign per category",
"for_each": "{{steps.s2.outputs.categories}}",
"item_var": "category",
"on_item_failure": "continue",
"uses_outputs": ["s1.staff_list"],
"instruction": "Fetch the pending jobs for {{category}} with db_query and assign them to staff rostered in {{category}} today. Handle only this category.",
"outputs": ["assignments", "unassigned_count"],
"output_format": "JSON object {assignments: [{staff_id, job_id}], unassigned_count: N}",
"tools": ["db_query"]
}
Each iteration's prompt carries one category, so its size stays constant however many categories there are. Every iteration is also told, in its own header, that it is handling one item of N and must not touch the others.
Map iterations do not automatically receive the previous step's whole answer.
Declare uses_outputs or an explicit output placeholder when an iteration
needs additional shared data. Retries keep the same resolved inputs and item
scope, along with feedback from the last attempt.
for_each is expanded against the run before it is read, so it normally names
an upstream value. A JSON array, one item per line, or a comma-separated line
all parse β but have the producing step publish a JSON array, which is the
only unambiguous form. An unresolvable reference fails the step rather than
iterating once over the error text.
{{item_index}} (1-based) and {{item_count}} are available inside every
iteration alongside your item_var.
Declared outputs are collected across iterations:
Original JSON types are preserved: a string ID such as "123" stays a string,
and null retains its position in the result list.
Failed iterations contribute nothing. The step's own output is a JSON document
listing every iteration with its item, status and answer β useful for auditing,
and what lands in the artifact file.
| Setting | Behaviour |
|---|---|
on_item_failure: "abort" (default) |
The first failing item fails the step. |
on_item_failure: "continue" |
Every item runs; the step passes if at least one did, and the aggregate records which failed. |
max_items |
Default 50, ceiling 500. Extra items are not run, rather than failing the step, so one over-broad upstream query cannot become thousands of LLM calls. The step's output says how many were left out (not_run_items and a note), so the step after it knows the results are incomplete. |
An empty collection is a pass, not a failure β there was simply nothing to do this run.
Cancellation fails the map even if some iterations already succeeded; completed results remain available for inspection, and no further items are started.
Iterations run sequentially. The gain is bounded prompt size and per-item
error isolation, not wall-clock time. for_each cannot be combined with
requires_approval; approve a summarising step before or after the map instead.
mcp_call)Some map steps do nothing for an item but one MCP tool call whose arguments come straight from the item: add each ticket of a list to a work plan, create each row in another system. Run through the model, every item costs an LLM round trip carrying the whole step prompt. On one pilot agent such a step made 349 calls through about 280 LLM calls of ~45k prompt tokens each, 13.9M tokens a day, although the model added nothing to any call.
Give such a step mcp_call instead of a brief, and the executor makes the
calls itself:
{
"id": "s3",
"name": "Add tickets to the plan",
"for_each": "{{steps.s2.outputs.tickets_ref}}",
"item_var": "ticket",
"max_items": 400,
"on_item_failure": "continue",
"tools": ["mcp"],
"mcp_call": {
"server": "planner",
"tool": "add_item",
"arguments": {"ticket_id": "{{ticket.id}}", "owner": "{{ticket.assignee}}", "day": "{{run.date}}"},
"timeout": "30s"
}
}
for_each may name a data_ref (a saved query step's
<as>_ref, or a ref another step published): each row of its table is an
object of its columns, named as data_compute names them (lower case, other
characters as _: Ticket ID is ticket_id). It may also be a JSON array
(objects keep their fields and types) or a plain list.{{ticket}} (the row), {{ticket.<field>}}, {{item_index}},
{{item_count}}, {{run.dateΒ±Nd}}, {{run.timeΒ±Nh|m}},
{{steps.<id>.outputs.<name>}} or {{steps.<id>.output}}. A placeholder
inside other text ("ticket {{ticket.id}}") is refused when the SOP is
saved: a row's value goes in as a whole argument, never spliced into a query
or command the server would read. Table cells are text; the tool's input
schema turns them into numbers or booleans where it asks for one.mcp.servers in config.yaml, or an
approved plugin server of the agent), never a URL, and the step must have
mcp among its tools (or the SOP's), as an LLM step would.results is a table with one line per row: its
status (ok, failed or not run), the arguments sent, what the server
answered and the error, each cell cut at 300 characters. results_ref is
the same table as a data_ref with every cell whole, for read_output,
data_compute or spreadsheet. The step's own output is the
counts and the failed rows, for the step after it. intent and
instruction are optional; outputs cannot be declared.Each call is the one the model would have made: mcp with call_tool,
through the worker pool. The Hooks page's rules see every call with its row's
arguments, every call is recorded in the run's effects journal before it is
made, and each shows in the trace. The step makes one attempt:
max_items (default 50, ceiling 500) fails the step before
the first call, instead of doing the first 50 of a list that is longer than
expected;on_item_failure works as for any map step: abort stops at the first
failed row and marks the rest not run, continue calls every row and
passes if one succeeded. A tool error the server answers with is a failed row;mcp_call.timeout limits one call (default 1m, between 1s and 10m);
the step's timeout limits all of them together;on_failure_goto leads back
to it, since every call would be made twice;Every value that moves between steps costs tokens twice: the producing step writes it, the consuming step reads it. Three mechanisms bound that, in increasing order of effectiveness:
Fan out instead of batching. A for_each step's prompt does not grow
with the collection at all β it is the only option on this list that is
bounded by construction.
Take the field, not the answer. uses_outputs: ["s1.staff_list"]
instead of ["s1"]. Typically halves the handoff, often much more.
Hand over a reference. Values past the inline limit
(sop.inline_output_chars, 12,000 characters) are cut in the prompt; the
step is told so and given a data_ref and the artifact path, and reads what
it needs with read_output or pages through the file with
read_file_lines.
Do not move the rows at all. The most effective option, and the one no handoff mechanism can substitute for: if a step must emit 5,000 rows, the model has to write 5,000 rows β slow, expensive, and where long lists get silently truncated or invented. Instead have the producing step publish a reference and let the consumer re-query:
{ "id": "s2", "outputs": ["jobs_query", "job_count", "categories"],
"instruction": "Do NOT return the rows. Return the SQL that selects them, the row count, and the distinct categories." }
s3 then runs jobs_query itself with db_query. Rows never enter a
prompt; only the query, a count, and a short category list do.
Rule of thumb: aggregates and identifiers travel through the prompt; rows do not. A step whose output grows with the size of the dataset is a step that will eventually break.
A tool result over tools.max_result_chars (default 8,000) β and every
db_query result regardless of size β is spilled out of the prompt into an
in-memory store, and the step receives a ref plus a schema-only preview
instead of the rows. It reads what it needs with read_output(ref, β¦), or hands
the ref straight to spreadsheet(data_ref: β¦) so the rows never enter a prompt
at all. See the tools engine for the full
mechanism.
That store evicts after an hour, keeps only the newest 200 entries, and is lost when the gateway restarts β while a SOP may run for two hours, and a step waiting on an approval can sit idle for one. A ref produced by an early step and read by a later one could therefore expire mid-run.
So inside a SOP the spilled result is also written under the run's own directory:
<workspace>/sop_runs/<sop_id>/<execution_id>/tool-<ref>.json
read_output falls back to that file when the in-memory entry is gone, and puts
it back in memory for the rest of the run. The file belongs to the execution
that produced it β another run of the same SOP cannot resolve the ref β and it
is swept with the rest of that run's artifacts under the same 20-run retention.
One consequence worth knowing: a rehydrated entry carries no pre-generated CSV,
because that file lives in the OS temp directory rather than in the run. A
spreadsheet(data_ref: β¦) call after a rehydration parses the content instead
of streaming the file β slower, same result.
Every output β and every named output β is written to a file:
<workspace>/sop_runs/<sop_id>/<execution_id>/<step_id>.md
<workspace>/sop_runs/<sop_id>/<execution_id>/<step_id>.<output_name>.md
Each step's prompt carries an index of the artifacts written so far in that
run, so a step can reach back past uses_outputs with file_read β without
anyone having to predict a filename.
The index lists what this step's prompt does not already carry (from 0.0.86). A value delivered in full through
uses_outputs, through a{{steps...}}placeholder, or as the previous step's output is not offered as a file, because a step shown a path reads it: on the pilot fleet a reporting step re-read its own upstream metrics in 37 of 53 runs, and another spent a tool call fetching a 10-character date it had been handed three paragraphs earlier. A truncated value keeps its entry β that path is the only way to the rest of it β and so does every step this one never asked for, which is what the index is for.
The execution ID is part of the path, so
concurrent runs of the same SOP never overwrite each other, and the files stay
on disk afterwards for debugging. sop_history reports each step's
artifact_path. The 20 most recent runs of each SOP are retained; older
run directories are removed when the SOP next executes.
Automatically injected values longer than 12,000 characters
(sop.inline_output_chars) are cut in the prompt. A note before the value says
how much of it is shown, the line the rest starts at, and where all of it is: a
data_ref that read_output reads (slice, search or sql), and the
artifact file. Declare file_read on any step that may receive a long list
β the validator warns when a step consumes upstream output without it β and
read_file_lines when it may need to page through one. A step without either
gets the value whole, however long. An explicit {{steps.s1.output}} reference
is never cut: naming a value by hand is taken as asking for all of it.
Output references are checked when the SOP is saved:
for_each combined with requires_approval β rejecteditem_var / max_items / on_item_failure set without for_each β rejected
(the step would have run once, which is not what the author meant)max_items above the ceiling, or an unknown on_item_failure β rejectedmcp_call without for_each, with a URL for server, without mcp in the
step's or the SOP's tools, with outputs, or with a placeholder inside other
text in its arguments β rejectedend_run_if or skip_if that is not one comparison as described in
Ending a run early, or skipping a step,
or that reads a step that runs later, a name that step does not publish, or
(skip_if) the step's own outputs β rejectedsuccess_check of expr: with no condition, or one that is not one
comparison, or that reads a step that runs later or a name that step does
not publish β rejectedThe Web UI also warns when a map step's instruction never mentions its
item_var (every iteration would get an identical brief), and when a map step
lists the collection it iterates in uses_outputs as well (every iteration
would carry the whole list on top of its own item).
The Web UI additionally warns when a step declares outputs but its
output_format does not mention JSON.
Failed steps are still addressable by ID; the prompt marks their output as not having passed its success check. A step that never ran in this execution (a branch skipped it) yields an explicit "did not run" marker rather than an invented value.
Unlike simple linear scripts, GoClaw SOPs can conditionally skip or loop steps based on verification criteria.
By setting on_failure_goto: "remediation_step", an agent will autonomously detour to fix a failed constraint. If successful, on_success_goto: "deploy_step" can jump back to the standard flow. (failure_mode only chooses between retry, abort and escalate β the jump target is its own field.)
on_success_goto and on_failure_goto jump on pass or fail, so a SOP whose
early step finds nothing to do still runs, and pays for, every step after it.
On one pilot agent a daily allocation SOP's third step found no staff for the
slot, and its instructions said to stop there. The run still went on through a
map over zero items, a report and an audit: 766k tokens for a run that
allocated nothing.
Two step fields let the executor make that decision itself, with no model call:
| Field | Checked | When its condition holds |
|---|---|---|
end_run_if |
after the step passes | The run ends there. No later step runs. |
skip_if |
before the step starts | The step does not run, and the run goes on to the next step. |
{
"id": "s3",
"name": "Open requests",
"outputs": ["requests", "rows"],
"output_format": "JSON object {requests: [{id, title}], rows: N}",
"end_run_if": "{{steps.s3.outputs.rows}} == 0"
}
{ "id": "s5", "name": "Report", "skip_if": "{{steps.s4.outputs.assignments}} == []" }
A condition is one comparison:
{{steps.<id>.outputs.<name>}}, or a value: a
number (0, -1.5), a quoted string ("changed" or 'changed'), true,
false, or [].== and != compare numbers when both sides are numbers, and text otherwise
(the published value trimmed; true and false ignore case). <, >, <=
and >= compare numbers only.[] means empty: a blank value, [], {}, null, or a saved query that
returned no rows. It goes with == or != only.and/or and no arithmetic, and a step's whole answer
({{steps.<id>.output}}) cannot be compared. A decision that needs more
belongs in the step that publishes the value: have it publish, say,
nothing_to_do: true.end_run_if reads the step's own outputs or an earlier step's; skip_if reads
earlier steps only. Both are checked when the SOP is saved.
A run that ends early is completed (or partial when an earlier step had
failed, with the usual alert) and counts as passed in the SOP's statistics; no
alert is sent for it. Its History entry says which step ended it and what held,
for example step "s3": end_run_if {{steps.s3.outputs.rows}} == 0 held (s3.rows = 0). So do its trace (the final output and the
sop_end_reason metadata), the cron job's last output and sop_history. Only
the steps that ran used tokens; a step that reaches the run's token limit and
ends the run completes it rather than failing it. The decision is saved with
the step, so a restart right after it does not go on to the steps it ended.
A skipped step shows in the History as β with its reason. It publishes nothing:
a later step that reads it gets the "did not run" marker, and {prev_output}
stays the last step that ran. It is not a failure.
A condition that cannot be decided does not hold: the step it reads did not run or did not pass, or a value that should be a number is not. The run then goes on as it would without the condition, and the step's record says why.
expr:)The other success_check rules read the step's answer as text, so none of
them can say "fail when these two numbers differ". On one pilot agent a daily
allocation SOP's audit step published 253 assigned against 200 expected and
passed; the report before it had said everyone got their share, and one person
had got none. The audit found the mismatch, and nothing could fail on it.
expr: takes one comparison, in the same language as end_run_if, and the
step passes only when it holds. The executor decides it from the values the
step published, with no model call:
{
"id": "s4",
"name": "Audit assignments",
"intent": "Count today's assignments against the jobs that were due",
"instruction": "Count the jobs assigned today and the jobs that were due today with db_query. Publish both counts as found.",
"tools": ["db_query"],
"outputs": ["assigned", "expected"],
"output_format": "JSON object {assigned: N, expected: N}",
"success_check": "expr:{{steps.s4.outputs.assigned}} == {{steps.s4.outputs.expected}}",
"failure_mode": "escalate"
}
{{steps.<its id>.outputs.<name>}}, from
the attempt being checked, and an earlier step's as usual. Each name it reads
must be one the step publishes: declare it in outputs (or as a query's
as).success_check expr:{{steps.s4.outputs.assigned}} == {{steps.s4.outputs.expected}} does not hold (s4.assigned = 253, s4.expected = 200). The failure takes the usual path: the step's retries, then its
on_failure_goto or failure_mode, and the admin alert carries the message.end_run_if, where a condition
that cannot be decided lets the run go on: a check that cannot tell is not a
pass.mcp_call step, on the values the step published, with no retry.Compare two published values, or one with a fixed value
(expr:{{steps.s4.outputs.unassigned}} == 0). The check is only as good as
the values: a count the step reads from the database is data, while a number
the model writes from memory is its word. Have the step publish what it
counted, with the tool that counted it.
Critical procedures (e.g., executing schema drops or sending broadcast emails) can be safeguarded by setting requires_approval: true.
When the agent reaches this step:
The step runs once and its answer is held: the step's job enters a
suspended state in the job queue, and the run waits.
A notice with the step's output and its job ID goes to sop.alerts in
config.yaml, or only to the gateway log when that is not set.
A human administrator approves or rejects it. In a chat with the agent, ask
it to approve (or reject) the SOP step with that job ID: its sop_approve
tool does it, and refuses anyone who is not an administrator, and any SOP,
cron or webhook run. Or call the API with the gateway token:
curl -X POST https://<gateway>/api/sop/approve \
-H "Authorization: Bearer $GOCLAW_TOKEN" -H "Content-Type: application/json" \
-d '{"job_id": "<job id from the notice>", "action": "approve"}'
To reject, send "action": "reject" and, optionally, a "reason".
Approved, the step's success_check decides whether it passed, and the run
goes on from there. Rejected, the step fails and its on_failure_goto or
failure_mode applies. A step that nobody approves or rejects within 1 hour
fails the same way.
The wait for approval lives in the running gateway. A restart ends it, and the run is then treated like any other run a restart cut off (below).
Each attempt of a step has a time limit, 15 minutes unless sop.step_timeout
in config.yaml or the step's own timeout says otherwise. A whole run is
still capped at 2 hours. An attempt that runs out of time is cancelled and
counts as a failed attempt. What happens next depends on what it had done:
failure_mode decides what happens when the retries run out.failure_mode says, and the admin is told.
The rollback is not run either, since the attempt may still be using what it
would undo; the alert names the steps whose rollback_command is left to a
person to decide.A crash in the SOP executor ends the run, not the gateway. The run ends
failed (the log shows [sop_v2] PANIC and the stack): the steps that
finished keep their results, the step it was on says panic: β¦, and the
admin is told through sop.alerts. The rollback is not run, since what the run
had done when it crashed is not known; the alert names the steps whose
rollback_command is left to a person to decide.
A run is saved as it goes: when it starts, and as each step starts and ends.
When the gateway is stopped or restarted in the middle of a run, the next start
finds it and marks it interrupted. It then does one of two things.
Resume from the step it was on, when all of these hold:
sop.resume_window ago (10 minutes by default; "0"
never resumes), no newer run of the SOP has started, and the SOP is enabled;failure_mode: abort or escalate, a rollback, an attempt that did not stop;The steps that finished are not run again; their results are carried over. The run's history shows where it was interrupted.
Otherwise close it and tell the admin through sop.alerts, with the reason
it was not resumed. Check what the step had done before running the SOP by
hand: the alert names its calls.
What counts as a call that sends or changes something: every tool call that
is not known to be read-only. Read-only are database queries (db_query, which
only runs SELECT, SHOW, DESCRIBE and EXPLAIN), Uptrace, Graylog, Wazuh and Grafana
queries, Zabbix *.get methods, metrics_store query and trend,
http_request GET and HEAD, memory and file
reads, and the date, calculation and text tools. Everything else counts β
message, notify, db_write, shell_exec, MCP tools, an Uptrace or Grafana
annotation, and any tool not listed β whether the call finished or not: a
message may or may not have arrived, a row may already be written. Such a call
is recorded before it is made; if the record cannot be written (a locked
database, a full disk), the call is refused and the step sees the error, rather
than make a call that recovery would not know about. A refused call changed
nothing, so it does not stop a timed-out attempt from being retried.
sop:
alerts: { channel: msteams, target: "<conversation id>" }
step_timeout: 15m
resume_window: 10m
max_run_tokens: 500000
heavy_run_tokens: 200000
inline_output_chars: 12000
max_output_tokens: 8192
A run stops at 500,000 tokens (sop.max_run_tokens), its steps, attempts and retries together. It is checked before every model call. A run that reaches it ends failed: it does not retry, does not go on to the next step, and its rollback runs. A cron job whose run stopped there is paused (token_limit on the Cron page), so it does not spend as much again at the next tick. A run past 200,000 tokens (sop.heavy_run_tokens) is marked heavy. A SOP whose early step can find nothing to do should stop there with end_run_if rather than run its later steps to say so. The run history shows what each run and step used. See Token Budgeting for what a normal run uses, and for raising or turning off the limit.
A step's result goes through several limits on its way to the steps after it. Each protects the run's token budget, and each says so when it cuts in:
| What | Limit | Setting | When it cuts in |
|---|---|---|---|
| A step's answer | 8,192 output tokens a model call (a chat reply on OpenAI and Ollama: 4,096). Claude, DeepSeek and Gemini agents keep their own limit when it is higher: ai.anthropic.max_output_tokens and ai.deepseek.max_output_tokens (16,000 by default, thinking included), ai.gemini.max_output_tokens (16,384). |
sop.max_output_tokens; 0 = the provider's own |
The model stops mid-answer. The step is asked again, for a shorter answer that gives counts and the data_ref of the rows rather than the rows. If its last attempt is cut too, its output ends with [This answer stopped at the model's output limit β¦], and a step that publishes outputs fails with that reason. |
| An earlier step's output in a step's prompt | 12,000 characters, for a step with file_read or read_file_lines |
sop.inline_output_chars; 0 = never cut |
The value is cut, with a note before it naming its data_ref and file. See Artifact files. |
| A saved-query step's output | 12,000 characters; 256K when every step that reads it has file_read or read_file_lines |
sop.inline_output_chars |
The step fails: query step output is N characters, exceeding the M-character limit. |
| A tool result inside a step | 8,000 characters | tools.max_result_chars |
The step sees a preview and a ref. See Large tool results inside a step. |
| A step's older tool results | Results older than the newest 4 rounds, over 2,000 characters | tools.full_result_rounds; -1 = never |
Each is cut to its first lines and the ref that reads it whole. |
| A step's context | 80,000 tokens | memory.auto_flush_tokens |
The step's older tool results leave the context; a note lists each with the ref that reads it whole. The step's own prompt, with the earlier steps' outputs, stays. |
mcp_call results |
300 characters a cell of results |
β | results_ref holds every cell whole. |
| A map step's items | max_items, 50 by default, ceiling 500 |
the step's max_items |
Items past it are not run, and the step's output says how many. |
| A run | 500,000 tokens | sop.max_run_tokens |
The run stops. See Token limit of a run. |
Every data_ref made in a run is also written under the run's directory, so it
still reads after the in-memory store's hour, or a restart. The run's record in
the database keeps every step's output whole: no column there is limited.
Every cut is shown. A run in which a limit cut data says β data cut in
the SOP's History tab; opened, each step lists its cuts under it (what was
cut, the setting, where the rest is), and the run's result keeps them as the
step's warnings. On the Traces page the trace has the same tag and a β
span under the step. See Where a limit cut data.
One exception, from 0.0.85 β read this before upgrading. A step that declares no
tools, in a SOP that declares none either, used to have its tools chosen for it at runtime by relevance ranking. It now runs text-only and every tool call it makes is refused. If such a step used to query a database or send a message, it will stop doing so silently β the run can still endcompleted. Give it the tools it needs, or"*", before you upgrade. A step that already declares its tools is unaffected. In the same release,"*"is understood for the first time: a SOP saved through the@sop:directive writestools: ["*"], and before 0.0.85 that meant no tools at all.
Otherwise, taking the binary update without migrating anything is safe. The data-handoff fields are opt-in, and a SOP that declares none of them is executed the way it always was:
{prev_output} and the "Previous step output" block behave identically.file_read or
read_file_lines β otherwise the value is passed through whole, however
large, exactly as before. A step is never handed a pointer it cannot follow.From 0.0.86 one more thing changes for every SOP: a run that reaches its last
step with any step failed ends partial instead of completed, and
run_stats counts it under Failed. Nothing about how the run executes changed β
failure_mode: retry still carries on to the next step β only what the record
says afterwards. A SOP that was quietly failing a step every run will start
reporting it; that is the point, and the number it reports was always the true
one.
Two things do change for every SOP, migrated or not:
sop_runs/<sop_id>/<exec_id>/<step_id>.md inside the workspace. The newest
20 runs per SOP are kept and older ones are swept on the next execution, so
growth is bounded, but a SOP with large outputs on a frequent schedule will
use noticeably more disk than before. A failed write is logged and the run
continues.running forever; it is now marked failed.SOPs written before the handoff model still run unchanged β and still carry its failure. sop-migration-prompt.md holds a prompt you hand to an agent so it audits and upgrades the SOPs it owns, with a diagnosis checklist and the guardrails to keep it from rewriting what already works.
json criteria for steps that should output structured data to ensure follow-up steps can parse it correctly.uses_outputs. Relying on {prev_output} alone silently loses everything older than one step.outputs on any step whose result is consumed in parts. A downstream step that needs one field should ask for one field.for_each step. One call per group beats one call for all of them, and a failure is isolated to its own item.<topic>_hourly, e.g. 0 0 * * * *) that records data via metrics_store with an evening summary SOP (<topic>_daily_summary, e.g. 0 0 22 * * *) that queries the day's metrics and prepares executive digests.