Skip to content
Beta. VectorStep is in beta.Let us know if something breaks.

Executors

AI backends are adapters behind a common interface; steps in the same pipeline can mix executors freely: gateway (VectorStep Gateway agents — the actively maintained path, see below), openclaw (best-effort support), human (Telegram/Slack/Teams approvals), webhook (HTTP POST out), shell (pre-approved local scripts), pipeline (sub-pipelines), and notify.

All executors implement BaseExecutor:

class BaseExecutor(ABC):
@abstractmethod
async def execute(self, step: StepConfig, context: dict) -> LLMOutput:
pass

Executors are referenced by name in pipeline YAML step executor: fields. Steps within the same pipeline can freely mix executors. Registered executors: gateway, openclaw, human, webhook, shell, notify, pipeline.

executor: gateway — Invokes agents via the VectorStep Gateway WebSocket API. Token-based auth (no device identity required). The VectorStep Gateway is a separate service that can run different model backends (Anthropic, OpenRouter, Ollama, Google) and MCP tool configurations independent of OpenClaw.

executor_config keys:

Key Required Description
agent Yes VectorStep Gateway agent name
session_key No Jinja2 template; must start with agent:{agent-name}:. Default: agent:{{agent}}:pipeline:{{pipeline_run_id}}:{{step_name}}
model No Model override string, e.g. anthropic/claude-sonnet-4-6, openrouter/..., ollama-cloud/...
thinking_level No Thinking level override: off|minimal|low|medium|high|xhigh. Overrides the agent’s own thinking_level default when both are set; pass off to opt a high-effort agent out for this step. Omit to inherit the agent default.
timeout_seconds No Per-request timeout override (default: 1200)
trace_max_chars No Overrides the Gateway’s limits.trace_tool_result_max_chars (default 3000) for this step’s tool_result trace events only — sent as traceToolResultMax on the agent request. Only affects the trace copy recorded/streamed for observability (and what grounding.max_trace_chars has available to hand the judge — see Confidence & the trust vector). The agent’s own conversation always sees the full, untruncated tool output regardless of this setting. Raise it on steps whose tools return long content if grounding or a human reviewing the trace is drawing false conclusions from evidence that was cut off before the Gateway ever sent it to VectorStep.

Requires executors.gateway.url (WebSocket) and executors.gateway.rest_url (REST) in config.yaml.

The VectorStep Gateway exposes three REST endpoints consumed by VectorStep:

Endpoint Purpose
GET /agents Agent list (name, model, model_fallbacks, tools)
GET /agents/{name}/soul soul.md content — shown in the Soul tab of the agent detail page
GET /agents/{name}/agent Raw agent.yaml content — shown in the Config tab of the agent detail page
OpenClaw executor Gateway executor
Auth Ed25519 device signature Bearer token
Session isolation Server-side (no file clearing) Server-side
Model routing OpenClaw agent config Gateway providers: config
MCP tools OpenClaw MCP servers Gateway mcp_servers: config
Thinking parameter thinking thinkingLevel
OTel trace propagation Not supported Supported — joins VectorStep’s trace

executor: openclaw — Invokes OpenClaw agents via the OpenClaw Gateway WebSocket API (ws://127.0.0.1:18789/rpc). Uses Ed25519 device-signature auth from ~/.openclaw/identity/. Fires an agent call and waits for the final result frame. Session isolation is server-side — no file deletion needed. Scans payloads in reverse for the last valid JSON block.

executor_config keys:

Key Required Description
agent Yes OpenClaw agent name
session_key No Jinja2 template; must start with agent:{agent-name}:. Default: agent:{{agent}}:pipeline:{{pipeline_run_id}}:{{current_step}}
model No Model override, e.g. anthropic/claude-sonnet-4-6. Overrides the agent’s configured model.
thinking_level No off|minimal|low|medium|high|xhigh — controls model thinking budget

Service-level config (under executors.openclaw in config.yaml, not per-step):

Key Default Description
url ws://127.0.0.1:18789/rpc OpenClaw Gateway WebSocket URL
identity_dir ~/.openclaw/identity Path to the directory containing device.json and device-auth.json. Override when OpenClaw is on a different machine and you have copied the identity files to a custom path.

The openclaw executor authenticates to the OpenClaw Gateway using Ed25519 device-signature auth. The required files are created automatically by OpenClaw — they are not something VectorStep creates:

File Created by Purpose
~/.openclaw/identity/device.json OpenClaw on first run / openclaw configure Device ID + Ed25519 private key
~/.openclaw/identity/device-auth.json OpenClaw device authorisation flow Operator token + scopes

Co-located setup (VectorStep and OpenClaw on the same machine): the files are already present and everything works automatically.

Remote OpenClaw (gateway on a different machine):

  1. Copy ~/.openclaw/identity/ from the OpenClaw machine to the VectorStep machine (or mount it as a Kubernetes Secret)
  2. Set executors.openclaw.identity_dir in config.yaml to the path where you copied the files
  3. On the OpenClaw machine, confirm VectorStep’s device is approved: openclaw devices list

If the files are missing: VectorStep logs a warning at startup and the openclaw executor is still registered, but any pipeline step using executor: openclaw will fail with a clear FileNotFoundError until the files are in place. The service continues to run normally — only openclaw steps are affected.

human — Human-in-the-Loop (Telegram, Slack, Microsoft Teams)

Section titled “human — Human-in-the-Loop (Telegram, Slack, Microsoft Teams)”

executor: human — Sends an approval request and pauses the pipeline until the operator approves or rejects, or timeout_seconds elapses. Which channel a run uses is resolved per-team (Telegram, Slack, or Microsoft Teams), not per-pipeline, so the same executor: human step works unchanged for every team. Testing-stage pipelines behave differently here too — approvals still fire, but routing and defaults account for the pipeline not yet being promoted to production.

The full mechanics — outcome/confidence mapping, per-team channel config, the Telegram/Slack/Teams credential requirements, and the /ui/approvals review flow — live on their own page: see Human-in-the-loop.

executor: webhook — POSTs the rendered prompt_template as the request body to a URL. Returns confidence=1.0 on any HTTP 2xx. Non-2xx raises and triggers the step’s retry/fail flow. The response body, truncated to 200 chars, is stored in next_step_context so downstream steps can reference it via {{steps.name.next_step_context}}. A separate raw_response field (status code + body, truncated to 500 chars) is persisted to the DB and viewable on the run detail page, but is deliberately excluded from every step’s Jinja2 context, so it’s never reachable from a downstream prompt_template.

That’s the whole picture for a plain-text or fire-and-forget target (a Slack incoming webhook, an internal endpoint that returns 204 No Content). If the target returns structured JSON you actually want to use in a later step — a REST API call, not just a notification — set parse_json: true and its top-level fields become individually addressable the same way a gateway step’s own JSON reply is: {{steps.name.<field>}}, for whatever field names the API happened to return.

executor_config keys:

Key Required Description
url Yes Target URL
method No HTTP method (default: POST)
content_type No Content-Type header (default: application/json)
headers No Extra headers; ${ENV_VAR} substitution supported
timeout_seconds No Per-request timeout (default: 30)
parse_json No Parse the response body as JSON and expose its top-level fields to downstream steps (default: false)
- name: notify-slack
executor: webhook
executor_config:
url: https://hooks.slack.com/services/...
headers:
Authorization: ${SLACK_TOKEN}
confidence_threshold: 0.0
on_low_confidence: proceed
prompt_template: |
{"text": "Alert resolved: {{labels.service}} — {{steps.triage.summary}}"}

Calling an API and using its response, parse_json: true:

- name: lookup-order
executor: webhook
executor_config:
url: "https://api.example.com/orders/{{order_id}}"
method: GET
parse_json: true
confidence_threshold: 0.0
on_low_confidence: proceed
prompt_template: "{}" # GET — body is unused but still required by the schema
- name: summarise-order
executor: gateway
executor_config:
agent: generic-pipeline-step
prompt_template: |
Order status: {{steps.lookup_order.status}}
Shipped to: {{steps.lookup_order.shipping_address}}
...

If lookup-order’s response is {"status": "shipped", "shipping_address": "...", ...}, both fields are reachable from summarise-order’s prompt exactly like that, with no extra parsing step in between.

executor: shell — Runs a script from the operator-managed security.shell_steps.allowed list and turns its exit code and stdout into an LLMOutput: confidence=1.0 on exit 0, confidence=0.0 otherwise. Use it for deterministic local logic a step needs to produce — a lint, a data transform, a call to an internal CLI tool — where the result feeds forward to later steps, not just gates the current one.

executor_config keys:

Key Required Description
script Yes Must match a name in security.shell_steps.allowed (see Service configuration reference). An unrecognized name aborts the run before anything spawns.
args No List of strings. Each is individually Jinja2-rendered, then passed as its own argv entry — never concatenated into a shell command line, so there is no shell-metacharacter injection class to defend against. A prior step’s output can safely flow into args even if it contains ;, $(...), or backticks: it’s always one literal argument.
env No Dict of env vars passed to the subprocess. Not inherited from the service process — unlike the type: shell check (which inherits the full environment, including provider API keys), a shell step gets nothing by default. Values support ${ENV_VAR} substitution, same convention as webhook’s headers.
timeout_seconds No Default 30. On timeout, the entire process group is killed (SIGKILL), not just the immediate script — this also reaps any child process the script spawned.
cpu_seconds / mem_bytes / file_bytes No RLIMIT_CPU / RLIMIT_AS / RLIMIT_FSIZE for the subprocess. Defaults: 30s CPU, 256MB address space, 10MB max output file size. Set per-step — a data-transform script and a one-line healthcheck have very different reasonable resource needs.
- name: restart_and_confirm
executor: shell
executor_config:
script: restart-service
args: ["{{ labels.service }}", "--reason", "{{ steps.investigate.summary }}"]
env:
DEPLOY_TOKEN: ${INTERNAL_DEPLOY_TOKEN}
timeout_seconds: 60
confidence_threshold: 1.0
on_low_confidence: escalate

Adding a script to the allowlist is a config.yaml change, not an API call. An operator adds an entry under security.shell_steps.allowed, naming the script, its path (relative to security.shell_steps.scripts_dir), and a required sha256:

security:
shell_steps:
enabled: true
scripts_dir: /etc/vectorstep/scripts
allowed:
- name: restart-service
path: restart-service.sh
sha256: 3f2a1c9e... # compute with: sha256sum restart-service.sh

The checksum is re-verified before every single execution, not just once at startup — a script swapped on disk after the service booted (a compromised volume mount, a less-trusted bind-mount source) is caught and the run aborts loudly rather than silently running the new content. A pipeline step whose script isn’t in the allowlist — or that references executor: shell at all while security.shell_steps.enabled is false — is rejected at write time with a 400 explaining that an operator, not the pipeline author, has to fix it; see Service configuration reference for the full write-time behaviour and every config key.

Output shape: next_step_context and raw_response.stdout are the script’s stdout, capped at 4000 characters; raw_response.stderr is capped at 2000. raw_response.exit_code is always present. Off by default, same posture as the type: shell check — security.shell_steps.enabled must be explicitly set to true.

executor: pipeline — Calls another named pipeline as a sub-pipeline. The sub-pipeline runs through the standard runner — full step execution, DB row, tracing — and its final step’s LLMOutput becomes the current step’s output. This turns pipelines into composable building blocks.

executor_config keys:

Key Required Description
pipeline Yes Name of the pipeline to call. Must be loaded and present in the pipeline registry at call time.
context No NormalisedContext field overrides. Scalar fields are Jinja2-rendered strings. labels and metadata are dicts — rendered keys are merged with the parent values (parent keys preserved; overrides add or replace individual keys).

The sub-pipeline inherits the parent’s NormalisedContext by default. The pipeline and source fields are updated (source → "sub-pipeline") and fingerprint is cleared (bypasses dedup). team is inherited unchanged like labels/metadata, so a shared sub-pipeline’s token spend rolls up to whichever team’s call triggered it — overridable via context: {team: "..."} like any other field. Use context: to pass step-specific values:

- name: triage
executor: pipeline
executor_config:
pipeline: shared-triage
context:
# Scalar field override — Jinja2-rendered
summary: "{{ steps.pre_filter.next_step_context }}"
# Dict field override — merged with parent labels
labels:
routed_by: "{{ pipeline_name }}"
metadata:
focus: "Check database connection pool first"
confidence_threshold: 0.75
on_low_confidence: escalate

Sub-pipeline DB linkage: the sub-pipeline runs with its own run_id, stored in pipeline_runs with parent_run_id set to the parent run’s ID. This makes sub-pipeline runs fully traceable — you can query SELECT * FROM pipeline_runs WHERE parent_run_id = '<parent-run-id>' to see all sub-pipeline invocations for a parent run.

Extra fields on the parent step output:

Field Description
sub_run_id The run_id assigned to the sub-pipeline run
sub_pipeline_status Terminal status of the sub-pipeline (completed, failed, escalated, etc.)

These are available downstream as {{ steps.triage.sub_run_id }} and {{ steps.triage.sub_pipeline_status }}.

Failure behaviour: if the sub-pipeline has a final_output (its last step ran and produced output), that output is used as-is regardless of sub-pipeline status. If the sub-pipeline has no final_output (it was aborted/escalated before any step completed), the parent step synthesises an LLMOutput with confidence=1.0 (completed) or confidence=0.0 (any other status).

Hot reload: POST /reload and SIGHUP update the pipeline registry, so changes to a sub-pipeline YAML take effect immediately without restarting the service.

Using executor: pipeline in a fan-out: each fan-out branch can delegate to a sub-pipeline, passing the branch item via context::

- fan_out:
name: per-service-triage
over: "{{ steps.identify_services.services }}"
as: service
executor: pipeline
executor_config:
pipeline: shared-triage
context:
labels:
service: "{{ service }}"
metadata:
focus: "Focus specifically on {{ service }}"
join: all_must_pass
confidence_threshold: 0.75
on_low_confidence: escalate

See ~/.vectorstep/pipelines/sub-pipeline-example.yaml for a complete worked example including conditional routing based on sub-pipeline output, and Chaining pipelines for the hands-on guide, including gotchas around trigger matching, cost accounting, and stage independence that don’t show up until you go looking.

Adding a new executor backend is an engine-source change — see Extending VectorStep for what’s available without source access, and Status & support for how to request one.