This is the abridged developer documentation for Sluice # Sluice > Flows are YAML files. Tasks are your scripts. Sluice runs them on a schedule, a webhook or a click, on a process, a container or a Kubernetes Job, and shows every run live. One Go binary with Postgres. Sluice is one binary: the server, the web UI, the CLI and an MCP server. It needs only Postgres. ```sh export SLUICE_BOOTSTRAP_ADMIN_PASSWORD=change-me-now-1 export SLUICE_MASTER_KEYS="k1:$(openssl rand -base64 32)" docker compose -f deploy/compose/compose.yml up -d ``` Open , sign in as `admin@local.test`, and run a flow. ![A failed execution: the failure triage with its cause and fix, the failed task selected in the waterfall, and its error in the log.](/_astro/execution-failed-light.CYhRSi9i_Z16ugPO.webp)![A failed execution: the failure triage with its cause and fix, the failed task selected in the waterfall, and its error in the log.](/_astro/execution-failed-dark.CrK4yi6N_Z1PRHUy.webp) Flows are files A flow is a YAML file next to the scripts it runs. Edit it in the browser, sync it from git, or push it from CI with `sluice namespaces push`. Every save is a version. Watch every run The execution page shows a live waterfall of the tasks. Select a task to see its attempts, outputs, metrics and logs. A failed run takes you to the failed task in one click. Built for agents `sluice init` gives a coding agent the flow rules. The CLI prints JSON and exits with the end state. The MCP server reads, runs and triages executions. The docs are in llms.txt. Runs where your code runs Tasks run as a local process, in a Docker container or as a Kubernetes Job. Pools route tasks to the instances that can run them. ## A flow [Section titled “A flow”](#a-flow) ```yaml id: nightly-load description: Load orders, then build the report. triggers: - { id: nightly, type: schedule, cron: "0 2 * * *", timezone: Europe/Zurich } tasks: - id: extract type: command command: ["python3", "pipelines/extract.py"] env: DB_URL: ${{ secret('DB_URL') }} - id: report type: command depends_on: [extract] command: ["echo", "rows: ${{ tasks.extract.outputs.rows }}"] ``` Install the CLI, then run the flow from a terminal, a CI job or an agent. The exit code is the end state: ```sh curl -fsSL https://raw.githubusercontent.com/alternayte/sluice/main/install.sh | sh sluice run sales/nightly-load --wait ``` ## Where to go next [Section titled “Where to go next”](#where-to-go-next) [Tutorials](/tutorials/run-your-first-flow/)Start Sluice, write a flow, run it and read the execution. Then build an ELT pipeline. [How-to guides](/how-to/run-flows-from-github-actions/)Schedules, webhooks, secrets, git sync, Docker and Kubernetes, CI, coding agents and MCP. [Concepts](/concepts/architecture/)How flows, executions, namespaces, executors and the assistant work, and why. [Reference](/reference/flow/)The flow file, the CLI, the environment variables, the MCP tools and the HTTP API. # Architecture > The parts of Sluice, the path of an execution through them, and how many instances share one database. This page explains the parts of Sluice and how an execution moves through them. It also explains how several server instances share the work without a coordinator. ## One binary, one database [Section titled “One binary, one database”](#one-binary-one-database) Sluice is one Go binary, `sluice`. The same binary is the server, the runner inside a task and the CLI. The server embeds the web UI. Postgres holds all state: users, namespaces, flows, executions, task runs, secrets and the task queue. An object store holds the large objects: file contents, bundles, archived logs and artifacts. The default object store is Postgres itself, so a deployment needs only a database. The server reads its configuration only from environment variables. You can run any number of server instances on one database. Each instance serves the UI and the API, claims tasks and runs executors. No instance has a special role that you configure. Clients and tasks call the HTTP API of any instance. Every instance uses the same Postgres and the same object storage. ## Components [Section titled “Components”](#components) | Component | Runs on | What it does | | --------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | HTTP API and UI | every instance | Serves the web UI at `/`, the API at `/api/v1`, MCP at `/mcp`, webhooks at `/hooks` and the runner API at `/api/runner/v1`. | | Engine and dispatcher | every instance | Starts queued executions, queues ready tasks, claims task runs and ends executions. | | Executors | the instance that claimed the task run | Start, wait for, cancel and check the work of a task run. | | Runner | the task process, container or Job | `sluice exec` gets the task spec and the files, runs the command, and reports to the server. | | Scheduler | the holder of the `scheduler` lease | Fires due schedules once each second. | | Git sync | the holder of the `git-sync` lease | Syncs due git sources once each second. | | Maintenance | the holder of the `maintenance` lease | Checks deadlines and lost work every 2 seconds, and cleans up every hour. | | Kubernetes reconciler | the holder of the `k8s-reconcile:` lease | Compares the Jobs and the task runs of one pool every 60 seconds. | | Instance registry | every instance | Records the instance with its pools and executors, and writes a heartbeat every 10 seconds. | | Storage | every instance | One interface for objects, with the drivers `postgres`, `fs`, `s3` and `azblob`. | ## The path of an execution [Section titled “The path of an execution”](#the-path-of-an-execution) A trigger creates an execution: a schedule, a webhook, a flow trigger, a click in the UI, `sluice run` or an MCP tool. The new execution row pins the current flow revision and the snapshot of the namespace. The execution starts in the state `QUEUED`. The engine moves the execution to `RUNNING` when the concurrency limit of the flow allows it. It creates one `PENDING` task run for each task. A task run becomes `QUEUED` when its dependencies have ended and its `run_if` condition is true. The dispatcher of any instance can claim a `QUEUED` task run. The claim is one database transaction with `SELECT … FOR UPDATE SKIP LOCKED`, in queue order. Two instances thus never claim the same task run. The claim sets the task run to `RUNNING` and creates a run token for it. The executor of the claiming instance then starts the work. It passes three variables to the runner: `SLUICE_API_URL`, `SLUICE_RUN_TOKEN` and `SLUICE_TASK_RUN_ID`. The runner runs the task and posts the result to the runner API. In one transaction, the engine sets the task state and applies the retry policy. It also queues the next tasks, and it ends the execution when all tasks have ended. The server then revokes the run token and archives the logs of the task run. `http` and `subflow` tasks follow the same states. They run inside the claiming instance, on the inline executor, with no runner. For the states and the reasons, see [Executions and states](/concepts/executions-and-states/). ## The runner protocol [Section titled “The runner protocol”](#the-runner-protocol) The runner is `sluice exec`. It calls the runner API of the server with its run token. The run token is valid for one task run only, and only while the task run is `RUNNING`. | Call | Purpose | | ------------------------ | -------------------------------------------------------------------------------------------- | | `GET …/spec` | The command, the resolved environment with the secrets, and the values to mask. | | `GET …/bundle` | The files of the pinned snapshot, as one archive. | | `POST …/logs` | Log lines in batches of at most 500 ms or 256 KiB. | | `POST …/events` | Outputs and metrics that the task writes to `SLUICE_OUTPUTS`. | | `PUT …/artifacts/{name}` | One artifact file. | | `POST …/heartbeat` | A liveness signal every 10 seconds. The answer tells the runner to stop when a user cancels. | | `POST …/complete` | The exit code, the error and the reason. | The paths start with `/api/runner/v1/task-runs/{taskRunId}`. The runner masks secret values before it sends logs, and the server masks them again before it stores them. This design keeps secrets out of the task definition. The server gives the resolved secrets to the runner at run time. They are not in a process environment of the server, a Docker container configuration or a Kubernetes object. For more, see [Executors, pools and the runner](/concepts/executors-pools-and-the-runner/). ## Snapshots and bundles [Section titled “Snapshots and bundles”](#snapshots-and-bundles) A snapshot is one immutable version of the files of a namespace. Sluice stores each file content once, by its SHA-256 hash. A snapshot is a manifest of paths, hashes, sizes and executable flags. A save in a managed namespace creates a snapshot. A git sync creates one when the files changed. An execution pins the head snapshot of its namespace when Sluice creates it. Later changes to the files do not change a pinned execution. Rerun and restart from failed use the snapshot and the flow definition of the old execution. The bundle of a snapshot is a `tar.gz` of all its files. The server builds it the first time a runner asks for it, and stores it for later tasks. The runner extracts the bundle into its work directory. The extraction rejects absolute paths, `..` segments, links and special files. ## Leases and leaders [Section titled “Leases and leaders”](#leases-and-leaders) Some work must run on one instance only, for example the scheduler. Sluice elects a leader for each such job with a row in the `leases` table. A lease has a time to live of 15 seconds, and its holder renews it every 5 seconds. When a holder stops, another instance takes the lease after it expires. | Lease | Work of the holder | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `scheduler` | Fires due schedules. Each schedule time creates at most one execution. | | `maintenance` | Checks flow and task deadlines, lost tasks and `no_instance_for_pool` every 2 seconds. Deletes old instances, sessions, audit events and executions, and collects unused storage objects. | | `git-sync` | Polls and syncs git sources. | | `k8s-reconcile:` | Reconciles the Jobs of one pool. Only instances with the kubernetes executor take it. | A write that only the leader may do checks the lease in the same SQL statement. A former leader thus cannot write after it lost the lease. Sluice uses no session advisory locks and no `LISTEN`, so it works behind a transaction-mode pooler such as PgBouncer. ## Heartbeats and lost work [Section titled “Heartbeats and lost work”](#heartbeats-and-lost-work) Each instance writes a heartbeat every 10 seconds. An instance is offline after 60 seconds without a heartbeat. Each runner sends a heartbeat every 10 seconds. Sluice finds lost work in three ways. The claiming instance checks each task run without a heartbeat for `SLUICE_HEARTBEAT_TIMEOUT` with its executor. The maintenance leader fails the task runs of offline instances. The Kubernetes reconciler fails a task run whose Job is gone. A lost attempt ends `FAILED` with the reason `lost`, and the retry policy applies. ## Logs [Section titled “Logs”](#logs) The runner sends log lines in batches. Each batch has a sequence number, so a repeated batch does not duplicate lines. The server masks each batch, numbers the lines, and stores the batch in Postgres. When the task run ends, the server writes all its lines to object storage as one compressed file and deletes the batches. The log API and the live log stream read both places and merge them by line number. ## Related pages [Section titled “Related pages”](#related-pages) * [Executors, pools and the runner](/concepts/executors-pools-and-the-runner/) * [Namespaces and versions](/concepts/namespaces-and-versions/) * [Security model](/concepts/security-model/) * [Deploy on Kubernetes with Helm](/how-to/deploy-on-kubernetes/) * [Runbook](/operations/runbook/) # Executions and states > How an execution moves from QUEUED to an end state, what a task run and an attempt are, and how rerun and restart differ. This page explains the life of an execution: its states, its task runs, the files that it uses, and how rerun and restart differ. [States and reasons](/reference/states-and-reasons/) lists every state and reason code. ## An execution is one run of a flow [Section titled “An execution is one run of a flow”](#an-execution-is-one-run-of-a-flow) An execution is one run of a flow, or of one file of a namespace. A trigger creates it. The execution records the trigger type, the trigger payload, the inputs, the labels and the user or system that started it. | `trigger_type` | Created by | | ------------------ | -------------------------------------------------------------------------------------------------- | | `manual` | **Run** in the UI, `sluice run`, or `POST /api/v1/flows/{namespace}/{flowId}/executions`. | | `schedule` | A [schedule trigger](/how-to/schedule-a-flow/). | | `webhook` | A call to the URL of a [webhook trigger](/how-to/trigger-a-flow-with-a-webhook/). | | `flow` | The end of an upstream execution. See [Chain flows](/how-to/chain-flows/). | | `subflow` | A `subflow` task of a parent execution. | | `file` | **Run** on a script file in the namespace editor. | | `rerun`, `restart` | **Rerun** on any execution, or **Restart from failed** on an ended execution that did not succeed. | ## The execution lifecycle [Section titled “The execution lifecycle”](#the-execution-lifecycle) An execution has one of eight states. | State | Meaning | | ------------ | ---------------------------------------------------------------------------- | | `QUEUED` | The execution exists and waits to start. | | `RUNNING` | Sluice has created its task runs and dispatches them. | | `CANCELLING` | A user asked to cancel. Sluice stops the running tasks. | | `SUCCESS` | Every task succeeded, or ended `SKIPPED` with `run_if_not_met`. | | `FAILED` | At least one task ended in another state, or a flow output failed to render. | | `TIMED_OUT` | The flow `timeout` ended the execution. | | `CANCELLED` | A cancel ended the execution. | | `SKIPPED` | The flow concurrency with `behavior: skip` refused the execution. | The allowed moves: | From | To | | ------------ | ---------------------------------------------- | | `QUEUED` | `RUNNING`, `SKIPPED`, `CANCELLED` | | `RUNNING` | `SUCCESS`, `FAILED`, `TIMED_OUT`, `CANCELLING` | | `CANCELLING` | `CANCELLED` | `SUCCESS`, `FAILED`, `TIMED_OUT`, `CANCELLED` and `SKIPPED` are end states. An ended execution never changes again. A new execution starts in `QUEUED`. Each server instance checks for queued executions at `SLUICE_QUEUE_POLL_INTERVAL`, one second by default. An instance moves the execution to `RUNNING` when the flow concurrency allows it. A flow with `concurrency: { limit: 1, behavior: queue }` keeps a second execution in `QUEUED` until the first one ends. [Retry, time out and limit executions](/how-to/retry-time-out-and-limit/) explains the limits. When the execution starts, Sluice creates one task run for each task, in `PENDING`. When all tasks have ended, Sluice computes the end state, renders the flow `outputs` and fires the flow triggers of downstream flows. These steps run in one database transaction. ## Task runs and attempts [Section titled “Task runs and attempts”](#task-runs-and-attempts) A task run is one attempt of one task. Each task starts with attempt 1. When an attempt ends `FAILED` or `TIMED_OUT` and the retry policy allows another attempt, Sluice creates a new task run with the next attempt number. The new attempt waits in `PENDING` until its backoff delay has passed. | Task run state | Meaning | | -------------- | --------------------------------------------------------------------------------------------------- | | `PENDING` | The task waits for its dependencies, for its retry delay, or for a free place under `max_parallel`. | | `QUEUED` | The task is ready. It waits for an instance of its pool to claim it. | | `RUNNING` | An instance claimed the task and runs it. | | `SUCCESS` | The task succeeded. | | `FAILED` | The task failed, for example with a non-zero exit code. | | `TIMED_OUT` | The task timeout ended the task. | | `CANCELLED` | A cancel stopped the task. | | `SKIPPED` | `run_if` did not allow the task to run. | The allowed moves are `PENDING` to `QUEUED`, `SKIPPED` or `CANCELLED`; `QUEUED` to `RUNNING` or `CANCELLED`; and `RUNNING` to `SUCCESS`, `FAILED`, `TIMED_OUT` or `CANCELLED`. The last attempt of each task decides the result of the execution. Templates and flow outputs read the outputs of the last attempt. Each ended task run has a reason when the state alone does not explain it, for example `exit_code`, `timeout`, `upstream_failed`, `template_error` or `lost`. `lost` means that the instance that ran the task stopped sending heartbeats. A lost attempt counts as `FAILED`, so the retry policy applies. ## The version that an execution uses [Section titled “The version that an execution uses”](#the-version-that-an-execution-uses) A new execution pins two things: * The current definition of the flow, merged with the defaults of `namespace.yaml`. * The head version of the namespace, that is the files that the tasks see. A save of the flow file or of a script after that point does not change the execution. The next execution uses the new version. [Namespaces and versions](/concepts/namespaces-and-versions/) explains versions. Variables and secrets are not part of a version. Sluice reads them when it dispatches each task. A variable that you change during a run reaches the tasks that start after the change. ## Rerun and restart from failed [Section titled “Rerun and restart from failed”](#rerun-and-restart-from-failed) Both actions create a new execution from an ended one. Both use the pinned version and definition of the original, with the same inputs and labels. Thus a change to the flow files does not reach them. To run changed files, run the flow again. | Action | Allowed for | What runs | | ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- | | **Rerun** | Any execution. | Every task, from the start. The trigger type is `rerun`. | | **Restart from failed** | `FAILED`, `TIMED_OUT` and `CANCELLED` executions. | Only the tasks whose last attempt did not end `SUCCESS`. The trigger type is `restart`. | A restart copies each task whose last attempt ended `SUCCESS` into the new execution. The copy ends `SUCCESS` with reason `reused` and keeps the outputs of the original. The other tasks run, and they read the reused outputs through templates. The execution page links the new execution to the original under **Restart of**. A restart of a `SUCCESS` or `SKIPPED` execution, or of one that has not ended, returns 409 `not_restartable`. A restart helps when the cause of a failure is outside the files: an endpoint that was down, or a secret that you changed. A fix in a script needs a new run. The same actions exist on the command line and in the API: ```sh sluice executions rerun 01a0d505-a483-7b0c-9428-70dd94359b01 --wait sluice executions restart 01a0d505-a483-7b0c-9428-70dd94359b01 --wait ``` ## Cancel [Section titled “Cancel”](#cancel) **Cancel** on the execution page, `sluice executions cancel` or `POST /api/v1/executions/{executionId}/cancel` stops an execution. * A `QUEUED` execution moves to `CANCELLED` at once. * A `RUNNING` execution moves to `CANCELLING`. Sluice cancels the tasks that have not started, and it asks the running tasks to stop. The runner sends SIGTERM, then SIGKILL after 10 seconds. A running subflow task cancels its child execution. When every task has ended, the execution moves to `CANCELLED`. * A second cancel of a `CANCELLING` execution changes nothing. * A cancel of an ended execution returns 409 `execution_ended`. ## Live updates in the UI [Section titled “Live updates in the UI”](#live-updates-in-the-ui) The execution page follows a running execution without a reload. It holds an event stream from `GET /api/v1/executions/{executionId}/events` and gets the execution with its task runs at each change. The log viewer holds a second stream for new log lines. When the execution ends, both streams close. ![A running execution of sales/nightly-load: the timeline shows two finished tasks and two running tasks, and the log viewer shows new lines.](/_astro/execution-live-light.61v0UK1m_Ze0BkB.webp)![A running execution of sales/nightly-load: the timeline shows two finished tasks and two running tasks, and the log viewer shows new lines.](/_astro/execution-live-dark.BDIkAoLd_4cAch.webp) The page shows: * A header with the state, the duration, the trigger, the version, the inputs and the labels. * A **Timeline** with one bar for each attempt, for example `extract_orders #1`. A selected bar opens a card with the executor, the queue wait, the exit code, the reason and the error. * An inspector with the tabs **Logs**, **Outputs**, **Metrics** and **Artifacts**. A selected task filters each tab to that task. * **Jump to first failure** when a task failed, and **Download JSON** for the execution with its task runs. The **Executions** page and the flow page refresh their lists every second. ## Next steps [Section titled “Next steps”](#next-steps) [States and reasons](/reference/states-and-reasons/) [Triage a failed execution](/how-to/triage-a-failed-execution/) [Retry, time out and limit executions](/how-to/retry-time-out-and-limit/) [Namespaces and versions](/concepts/namespaces-and-versions/) # Executors, pools and the runner > Why Sluice has four executors, how pools route tasks to instances, how worker slots limit the work, and what the runner does inside a task. This page explains where a task runs and why. An executor starts the work of a task run. A pool connects tasks to the instances that can run them. The runner is the part of Sluice that runs inside the task and reports back. ## Four executors [Section titled “Four executors”](#four-executors) All executors use one interface: start, wait, cancel and status. The engine does not know how an executor starts the work. A task run keeps the external reference of its work, for example a container ID or a Job name. | Executor | Task types | Where the work runs | Isolation | After a server stop | | ------------ | ------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------- | | `inline` | `http`, `subflow` | A goroutine in the instance that claimed the task. | None. | The task ends with `instance_shutdown`. | | `process` | `script`, `command` | A `sluice exec` child process of the server. | A separate process group. It shares the file system and the tools of the server. | The task ends with `instance_shutdown`. | | `docker` | `script`, `command` | A new container for each attempt, through the Docker Engine API. | The container. Any image, limits on CPU and memory. | The container continues. | | `kubernetes` | `script`, `command` | A new Job for each attempt. | The pod. Requests, limits, node placement, service accounts. | The Job continues. | You cannot select `inline` in a flow. `http` and `subflow` tasks do only network calls or start other executions, so they need no process of their own. ### Choose an executor [Section titled “Choose an executor”](#choose-an-executor) The **process** executor has the smallest overhead. It needs the tools of the task on the server, for example `uv` for Python scripts. The `sluice-uv` image holds `bash`, `uv`, Python 3.12 and `bun` for this case. Use it for a single host, a trial, or tasks that trust each other. The **docker** executor gives each attempt a clean container from an image that you choose. Use it on one host when tasks need different tools, or when a task must not change the server file system. The **kubernetes** executor gives each attempt a Job. The cluster places the pod by its requests, node selector and tolerations. Use it for production work in a cluster, and for tasks that need more resources than one server has. A docker or kubernetes task continues when the server stops, for example during a rollout. The runner in the task sends its data to any instance. A process task stops with its instance, and the retry policy applies. ## Which executor a task gets [Section titled “Which executor a task gets”](#which-executor-a-task-gets) A task gets its executor from four levels, in this order: the task, the flow, `defaults.executor` of `namespace.yaml`, and the instance default. Sluice merges the levels field by field. A level replaces only the fields that it sets. A task can thus set only `pool` and keep the type and the image of the flow. After the merge, an empty `type` becomes `process` and an empty `pool` becomes `default`. The instance default is always `process`. No variable changes it. The validator checks the merged result. It reports `image_required` when a docker or kubernetes task has no image. It reports `field_not_allowed` when a block sets a field that its type does not accept, for example `pull` on a kubernetes block. It reports `executor_not_allowed` when an `http` or `subflow` task has an executor block. ## Which executors an instance has [Section titled “Which executors an instance has”](#which-executors-an-instance-has) `SLUICE_EXECUTORS` selects the executors of an instance. With the default `auto`, the instance detects them at start: | Executor | Rule with `auto` | | ------------ | ---------------------------------------------------------------------------- | | `inline` | Always on. | | `process` | Always on. | | `docker` | On when the Docker API answers a ping within 2 seconds. | | `kubernetes` | On when a Job create dry run in the Job namespace succeeds within 5 seconds. | An explicit list, for example `process,kubernetes`, turns on exactly these executors plus `inline`. It does no detection. A list without `process` turns the process executor off. Each instance records its pools and executors. An admin sees them on **Settings → Instances**. ## Pools route tasks [Section titled “Pools route tasks”](#pools-route-tasks) A pool is a name that connects tasks to instances. `SLUICE_POOLS` lists the pools of an instance. The default is `default`. A task goes to the pool in its `executor.pool`. An instance claims a task run only when two conditions are true. The pool of the task is in its `SLUICE_POOLS`, and the executor of the task is on. Inline tasks have no pool. Any instance can claim them. Pools let you place work without a scheduler of your own: * An instance on a host with a GPU serves the pool `gpu`. Only tasks with `pool: gpu` go there. * An instance in a second cluster serves the pool `cluster-b`. It uses the same database, storage and master keys. * An instance with `SLUICE_WORKER_SLOTS=0` serves the UI and the API, and claims no process or docker task. When no online instance serves the pool and the executor of a queued task, the task shows the reason `no_instance_for_pool`. It stays `QUEUED`. It starts when such an instance comes online. ## Slots limit the work [Section titled “Slots limit the work”](#slots-limit-the-work) Each claim takes a slot. When no slot is free, the task run waits in the queue. | Limit | Variable | Default | Scope | | ------------------------ | --------------------- | ------- | ------------------------------------------------------- | | Process and docker tasks | `SLUICE_WORKER_SLOTS` | `8` | One instance. Process and docker tasks share the slots. | | Kubernetes Jobs | `SLUICE_K8S_MAX_JOBS` | `50` | One pool, over all instances. | | Inline tasks | none | `64` | One instance. | The dispatcher claims the oldest queued task runs first. It polls the queue every `SLUICE_QUEUE_POLL_INTERVAL`, and also when a local task ends. The concurrency limit of a flow and `max_parallel` apply before the claim. See [Retry, time out and limit executions](/how-to/retry-time-out-and-limit/). ## The runner [Section titled “The runner”](#the-runner) The runner is the command `sluice exec` of the same binary. It runs next to the task command on the process, docker and kubernetes executors. It does these things for one task run: * It reads the task spec and the resolved environment, secrets included, from the runner API. * It downloads the bundle of the pinned snapshot and extracts it into the work directory. * It runs the command, and sends the log lines, the outputs, the metrics and the artifacts. * It sends a heartbeat every 10 seconds. The answer tells it when a user cancels the execution. * It posts the exit code and the error. The executor gives the runner only three variables: `SLUICE_API_URL`, `SLUICE_RUN_TOKEN` and `SLUICE_TASK_RUN_ID`. The run token is valid for this task run only, and it expires at the task timeout plus 10 minutes. The server revokes it when the task run ends. This design has two results. First, no secret value is in a server process environment, a container configuration or a Kubernetes Job. Second, all three executors use one protocol, so logs, outputs and cancels behave the same on each of them. The process executor passes only a short list of server variables to the runner, for example `PATH`, `HOME`, `TZ` and the proxy variables. The server configuration, for example `SLUICE_DATABASE_URL` and `SLUICE_MASTER_KEYS`, does not reach tasks. ### Runner injection [Section titled “Runner injection”](#runner-injection) The runner must be in the container of the task. With `inject_runner: true`, the default, Sluice puts it there, so any Linux image can run a task: | Executor | How the runner gets into the task | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `docker` | The server copies the binary once from `SLUICE_RUNNER_IMAGE`. For each task, it copies the binary into the created container at `/sluice-bin/sluice` through the Engine API. No volume. | | `kubernetes` | An init container from `SLUICE_RUNNER_IMAGE` runs `sluice runner-install /sluice-bin`. It writes the binary into an `emptyDir` that the task container mounts. | With `inject_runner: false`, the task container runs `sluice exec` from the `PATH` of the image. Sluice copies nothing and adds no init container. Use this option with an image that holds the binary, for example `sluice-uv` or an image built from it. ### The Sluice images [Section titled “The Sluice images”](#the-sluice-images) Both images hold the binary at `/usr/local/bin/sluice` and run as the non-root user 65532. | Image | Base | Contents | | ----------- | ----------------- | ---------------------------------------------------------------------------- | | `sluice` | distroless static | The `sluice` binary only. No shell. | | `sluice-uv` | Debian slim | The binary, `bash`, `ca-certificates`, `git`, `uv`, a Python 3.12 and `bun`. | ## Cancels and lost work [Section titled “Cancels and lost work”](#cancels-and-lost-work) A cancel reaches each executor in its own way. The runner sends SIGTERM to the task process group, and SIGKILL after 10 seconds. The docker executor stops the container with a grace time of 10 seconds and removes it. The kubernetes executor deletes the Job. When the work of a task run stops without a result, the task run ends `FAILED` with the reason `lost`. Examples are a `docker kill`, an evicted pod, or a claiming instance that went offline. The retry policy applies to a lost attempt like to any failure. ## Related pages [Section titled “Related pages”](#related-pages) * [Run tasks in Docker](/how-to/run-tasks-in-docker/) * [Run tasks on Kubernetes](/how-to/run-tasks-on-kubernetes/) * [Architecture](/concepts/architecture/) * [Flow file reference](/reference/flow/) * [Environment variables](/reference/env/) # Flows, tasks and templates > How a flow file declares tasks, dependencies, inputs, environment and files, and where Sluice validates it. This page explains what a flow is, how its tasks run, and how values move into a task. The complete field list is in [Flow file](/reference/flow/). The expressions are in [Templates](/reference/templates/). ## A flow is a file [Section titled “A flow is a file”](#a-flow-is-a-file) A flow is one YAML file in a [namespace](/concepts/namespaces-and-versions/). The file name ends in `.flow.yaml` or `.flow.yml`, and the file can be in any directory of the namespace. One file holds one flow. The scripts that the flow runs are files in the same namespace, so a flow and its code change together in one version. | Rule | Value | | ----------------- | -------------------------------------------------------------------------------------------------- | | Flow `id` | Lower-case letters, digits and hyphens. It starts with a letter or a digit. At most 63 characters. | | Unique `id` | Two files with the same `id` in one namespace are both invalid (`duplicate_flow_id`). | | Tasks | From 1 to 200. | | Paths in the flow | Relative to the namespace root. No leading `/`, no `..` segment, at most 512 characters. | A flow does not run on its own. A trigger creates an execution from it: a click on **Run**, a schedule, a webhook call, the end of another flow, or a subflow task. [Executions and states](/concepts/executions-and-states/) explains what happens then. ![The namespace editor with the file nightly-load.flow.yaml open: a flow with an input, a schedule trigger, a retry policy and four tasks.](/_astro/editor-light.C2e9rSSu_2qs1DR.webp)![The namespace editor with the file nightly-load.flow.yaml open: a flow with an input, a schedule trigger, a retry policy and four tasks.](/_astro/editor-dark.BHfXrbQv_26oJ5A.webp) ## Task types [Section titled “Task types”](#task-types) Every task has an `id` and a `type`. The type decides where the task runs and which fields it takes. | Type | What it does | Required field | Runs on | | --------- | -------------------------------------------- | -------------- | ----------------------------------------- | | `script` | Runs a file of the namespace with a runtime. | `file` | An executor. | | `command` | Runs an argv list. There is no shell. | `command` | An executor. | | `http` | Sends one HTTP request. | `url` | The server instance that claims the task. | | `subflow` | Starts another flow as a child execution. | `flow` | The server instance that claims the task. | A field of another type fails validation with `field_not_allowed`, for example `url` on a `script` task. A `script` task picks its runtime from `runtime`, or from the file extension: | `runtime` | Extension | Command | | --------- | --------------------- | ----------------------- | | `python` | `.py` | `uv run ` | | `bash` | `.sh` | `bash ` | | `bun` | `.ts` | `bun run ` | | `node` | `.js`, `.mjs`, `.cjs` | `node ` | A file with another extension needs `runtime` (`unknown_runtime`). When the tool is not on the executor, the task fails with reason `runtime_not_found`. A `command` task runs its argv list directly. To use pipes or globs, run a shell: `["sh", "-c", "wc -l data/*.csv"]`. An `http` task records the outputs `status`, `headers` and `body`. The body holds at most 1 MiB, and a JSON body becomes a JSON value. A status outside `expect_status`, which defaults to 200 to 299, fails the task with reason `http_status`. A `subflow` task names its child as `/`. [Chain flows](/how-to/chain-flows/) shows how to use it. `script` and `command` tasks run on an executor: a process, a Docker container or a Kubernetes Job. `executor` on an `http` or `subflow` task fails validation with `executor_not_allowed`. [Executors, pools and the runner](/concepts/executors-pools-and-the-runner/) explains the executors. ## Dependencies and run\_if [Section titled “Dependencies and run\_if”](#dependencies-and-run_if) `depends_on` lists the tasks that must end before a task starts. Tasks without dependencies start at once. Independent tasks run in parallel, up to `max_parallel`. Validation rejects an unknown task, a task that depends on itself, a duplicate entry and a cycle. When all dependencies have ended, `run_if` decides whether the task runs: | `run_if` | The task runs when | Otherwise the task ends `SKIPPED` with | | ------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `success` (default) | every dependency ended `SUCCESS`. | `upstream_failed` when a dependency ended `FAILED`, `TIMED_OUT`, `CANCELLED` or `SKIPPED` with `upstream_failed`. Else `run_if_not_met`. | | `failure` | at least one dependency ended `FAILED` or `TIMED_OUT`. | `run_if_not_met` | | `always` | every dependency has ended, in any state. | — | A task with `run_if: failure` and no dependencies never runs. The execution ends `SUCCESS` when every task ended `SUCCESS`, or `SKIPPED` with `run_if_not_met`. So a cleanup task with `run_if: failure` does not turn a good run into a failure. Any other end state of a task makes the execution `FAILED`. ```yaml id: load-with-alert tasks: - id: load type: script file: pipelines/load.py - id: publish type: command depends_on: [load] command: ["echo", "published"] - id: alert type: http depends_on: [load] run_if: failure method: POST url: ${{ vars.ALERT_URL }} body: '{"text": "load failed in ${{ execution.id }}"}' ``` When `load` succeeds, `publish` runs and `alert` ends `SKIPPED` with `run_if_not_met`. The execution is `SUCCESS`. When `load` fails, `alert` runs and `publish` ends `SKIPPED` with `upstream_failed`. The execution is `FAILED`. ## Inputs [Section titled “Inputs”](#inputs) Inputs are the parameters of a flow. A trigger gives their values, and Sluice checks the values before it creates the execution. | Input `type` | Accepts | | ------------ | --------------------------------- | | `string` | A string. | | `int` | A whole number. | | `number` | Any number. | | `boolean` | `true` or `false`. | | `select` | One of the `values` of the input. | | `json` | Any JSON value. | An unknown input, a value of the wrong type, or a missing `required` input without a `default` rejects the trigger. A manual run then returns 422 `validation_failed` with one detail for each input. A `default` that does not match its type fails validation with `invalid_input_default`. The **Run** dialog on the flow page shows one field for each input. `sluice run sales/nightly-load --input run_date=2026-09-24` gives an input from the command line. An optional input without a default and without a value has no value. A template that reads it fails the task with `template_error`. ## Environment [Section titled “Environment”](#environment) The environment of a task comes from three maps. A later map overrides an earlier map key by key: 1. `defaults.env` of `namespace.yaml`. 2. The flow `env`. 3. The task `env`. The values are templates, and they accept `secret()`. That makes `env` the place for credentials: the task reads a secret as an environment variable, and the secret never appears in `args` or `command`. Sluice also sets these variables for every `script` and `command` task: | Variable | Value | | --------------------- | ------------------------------------------------------------------------------------------------------ | | `SLUICE_EXECUTION_ID` | The execution ID. | | `SLUICE_TASK_ID` | The task ID. | | `SLUICE_ATTEMPT` | The attempt number, from 1. | | `SLUICE_NAMESPACE` | The namespace of the flow. | | `SLUICE_FLOW_ID` | The flow ID. | | `SLUICE_OUTPUTS` | The path of the outputs file. [Pass data between tasks](/how-to/pass-data-between-tasks/) explains it. | | `SLUICE_WORKDIR` | The directory that holds the namespace files. | ## Files [Section titled “Files”](#files) Some tools read their configuration from a file, for example a dbt `profiles.yml`. The `files` map of a `script` or `command` task writes such files before the task starts. Each key is a path relative to the namespace root. Each value is a template, and it accepts `secret()`. ```yaml id: dbt-run tasks: - id: dbt_run type: command command: ["dbt", "run", "--profiles-dir", "."] files: profiles.yml: | warehouse: target: prod outputs: prod: type: postgres host: ${{ vars.DB_HOST }} password: ${{ secret('DB_PASSWORD') }} ``` The runner writes each file into the working directory with mode `0600`, and it creates the parent directories. A file replaces a namespace file at the same path for this task only. The version of the namespace does not change. A key cannot hold a template (`template_not_allowed`), and a key that is not a valid relative path fails with `invalid_path`. ## Templates [Section titled “Templates”](#templates) A template is `${{ expr }}` in a string value. The expression is a lookup. It reads an input, a variable, a secret, a task output, a trigger field or a fact of the execution. There are no operators. This keeps a flow file readable and lets the validator check every reference before anything runs. Sluice renders the templates of a task when it dispatches the task, not when you save the flow. Thus a task reads the current value of a variable or a secret, and the outputs of the tasks that ended before it. A lookup that fails at that point fails the task with `template_error`, and no process starts. [Templates](/reference/templates/) lists every expression and the fields that accept it. ## Validation [Section titled “Validation”](#validation) Sluice validates a flow in three places, with the same code and the same error codes: | Place | When | What you see | | ----------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | The namespace editor | While you type in a flow file or `namespace.yaml`. | A list of errors under the editor, each with line, column, code and message. | | The server | At each save, push or git sync. | The flow page shows **Invalid** and an **Errors** table. The triggers of the flow are inactive, and **Run** returns 422 `flow_invalid`. | | `sluice validate ` | On your machine or in CI, without a server. | One line for each file. The exit code is 1 when a file is invalid. | The server keeps an invalid flow, so you can see and fix it. An invalid flow starts no new executions until a valid version replaces it. Executions that already run keep the definition that they pinned. ```sh sluice validate examples/elt/namespace sluice validate examples/elt/namespace --json ``` `--json` prints an object that matches the [validate result schema](/reference/schemas/). The flow schema also helps an editor or a coding agent. Add this line at the top of a flow file to get completion and checks in editors that support it: `# yaml-language-server: $schema=https://sluice-docs.pages.dev/schemas/flow.schema.json`. ## Next steps [Section titled “Next steps”](#next-steps) [Pass data between tasks](/how-to/pass-data-between-tasks/) [Retry, time out and limit executions](/how-to/retry-time-out-and-limit/) [Flow file reference](/reference/flow/) [Templates reference](/reference/templates/) # How Sluice compares > A factual comparison of Sluice with Kestra, Airflow, Dagster, Prefect, Windmill and Temporal, for a reader who chooses a tool. This page compares Sluice with six other orchestration tools: Kestra, Airflow, Dagster, Prefect, Windmill and Temporal. It states what Sluice is and is not, and when another tool fits better. ## What Sluice is [Section titled “What Sluice is”](#what-sluice-is) Sluice is a self-hosted flow orchestrator. It is one Go binary that holds the server, the web UI, the CLI, the runner and an MCP server. Postgres holds all state. It needs no other service. A flow is a YAML file in a namespace, next to the scripts that it runs. A task is a script (Python through uv, bash, bun or node), a command, an HTTP call or a subflow. Sluice runs tasks inline, as a process, in a Docker container or as a Kubernetes Job. Schedules, webhooks and the end of another flow start executions. The UI shows each execution live, with a waterfall of the task runs and their logs. Sluice gives coding agents and CI jobs the same access as people. The CLI prints JSON and exits with the end state of an execution. The MCP server reads, runs and triages executions. `sluice init` writes an agent skill into a repository. The docs publish `llms.txt` and a Markdown copy of each page. ## What Sluice is not [Section titled “What Sluice is not”](#what-sluice-is-not) * It is not a durable execution engine. It does not replay workflow code after a crash. A lost task run fails, and its retry policy applies. * It has no data asset model. It does not track tables, lineage or the freshness of data. * It has no plugin catalog. A task calls your own script, a command or an HTTP endpoint. * It has no single sign-on. It has its own user accounts and four fixed roles, with no roles for each namespace. * It is one environment for each deployment. It has no tenants. * It is before version 1.0.0. A minor version can break compatibility. ## Comparison table [Section titled “Comparison table”](#comparison-table) The facts about the other tools come from their public documentation in September 2026. A dash means that this page makes no claim. | Tool | Deployment footprint | Flow definition | Where tasks run | UI | CLI | MCP and agent support | License model | | ------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | | **Sluice** | One Go binary and Postgres. | YAML flow files in namespaces. Tasks are scripts, commands, HTTP calls and subflows. | Inline, process, Docker and Kubernetes executors, in named pools. | Web UI in the binary. | `sluice`, the same binary. | MCP server at `/mcp`, MCP server card, an assistant in the UI, an agent skill from `sluice init`, `llms.txt`. | Open source under the GNU AGPL 3.0. | | **Kestra** | A Java server on the JVM, with a database. | YAML flows in namespaces. Tasks come from plugins. | Plugins and task runners. | Web UI with a flow editor and a Copilot sidebar. | `kestra` | MCP server, Copilot sidebar, `llms.txt`. | Open source core, with a commercial enterprise edition. | | **Windmill** | A Rust server and workers, with Postgres. | Scripts in many languages, and flows that chain scripts. | Workers in worker groups. | Web UI with script and flow editors. | `wmill` | An MCP gateway for each instance. | Open source core, with a commercial enterprise edition. | | **Prefect** | A Python server with a database, and workers. A hosted service exists. | Python functions with `@flow` and `@task` decorators. | Workers in work pools. | Web UI. | `prefect` | A read-only MCP server. | Open source, with a hosted commercial service. | | **Dagster** | A web server, a daemon and code locations, with a database. A hosted service exists. | Python. The unit is the data asset. | Run launchers and executors. | Web UI with the asset graph. | `dagster`, `dg` | An MCP server. | Open source, with a hosted commercial service. | | **Airflow 3** | A scheduler, an API server, a DAG processor, workers and a metadata database. | Python DAGs. | Executors, for example Celery or Kubernetes. | React web UI. | `airflow` | No first-party agent tooling. | Open source, an Apache Software Foundation project. | | **Temporal** | The Temporal service with a persistence store, and your workers. A hosted service exists. | Durable workflows as code in an SDK. It is not a scheduler of scripts. | Your worker processes. | Web UI for workflow histories. | `temporal` | — | Open source, with a hosted commercial service. | ## When to choose Sluice [Section titled “When to choose Sluice”](#when-to-choose-sluice) Sluice fits these cases: * You have scripts and commands, and you want them on a schedule or a webhook with logs, retries and secrets. * You want one binary and one Postgres database, with no message broker and no separate scheduler or worker services. * You want flows as reviewable YAML files in git, next to the scripts, with a validator that runs offline in CI. * You want coding agents and CI jobs to run and debug flows through a CLI with JSON output, MCP tools and an agent skill. * You run on Kubernetes and want each task as a Job, or on one host and want each task as a process or a container. ## When to choose another tool [Section titled “When to choose another tool”](#when-to-choose-another-tool) Another tool fits these cases better: * **Kestra**: you want YAML flows with a catalog of ready-made plugins for databases, clouds and SaaS products. Its enterprise edition adds features such as single sign-on. * **Airflow**: your team writes Python DAGs, uses the Airflow provider packages, or runs Airflow already. * **Dagster**: you think in data assets, and you need lineage, freshness and asset checks across a data platform. * **Prefect**: your workflows are Python code, and you want to add orchestration to existing Python functions with decorators. * **Windmill**: you want scripts in many languages with generated UIs and internal apps on top of them. * **Temporal**: you need durable, long-running workflows as code that survive process crashes and continue from the last step, for example in business transactions. Sluice also does not fit when you need single sign-on, namespace-level roles or several isolated tenants in one deployment. ## Related pages [Section titled “Related pages”](#related-pages) * [Architecture](/concepts/architecture/): the components of Sluice. * [Executors, pools and the runner](/concepts/executors-pools-and-the-runner/): where tasks run. * [The assistant and MCP](/concepts/the-assistant-and-mcp/): the agent features. * [Run your first flow](/tutorials/run-your-first-flow/): start Sluice and run a flow. # Namespaces and versions > How namespaces group flows and files, how every change becomes a version, and how managed and git namespaces differ. This page explains namespaces: the trees of files that hold flows and scripts. It covers the name hierarchy, the two kinds of namespace, versions, and the ways to change files. ## A namespace is a tree of files [Section titled “A namespace is a tree of files”](#a-namespace-is-a-tree-of-files) A namespace holds flow files, the scripts that they run, `namespace.yaml` and any other file that a task needs. A task sees the files of its namespace in its working directory. Every path in a flow is relative to the namespace root. | Rule | Value | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Name | Lower-case letters, digits and hyphens, in parts separated by dots, for example `sales` or `sales.eu`. A part starts and ends with a letter or a digit. At most 128 characters. | | File path | Relative, UTF-8, at most 512 characters, no empty, `.` or `..` segment. | | File size | At most `SLUICE_MAX_FILE_BYTES`, 10 MiB by default. | | Version size | All files together at most `SLUICE_MAX_BUNDLE_BYTES`, 200 MiB by default. | ## The name hierarchy [Section titled “The name hierarchy”](#the-name-hierarchy) The dots in a name make a hierarchy. `sales` is the parent of `sales.eu`. The hierarchy has three uses: * **Secrets and variables.** A task in `sales.eu` searches its own namespace, then `sales`, then the global scope. The nearest definition wins. [Use secrets and variables](/how-to/use-secrets-and-variables/) shows this. * **Filters.** A namespace filter on the **Executions** page, the dashboard and the API includes the child namespaces. * **Navigation.** The **Namespaces** page shows the namespaces as a tree. A parent does not need to exist. When you create only `sales.eu`, the tree shows `sales` as an implicit parent. An implicit parent has no files, secrets or variables. To give it secrets or variables, create it first. A write to an implicit parent returns 404 `namespace_not_found`. ## Managed and git namespaces [Section titled “Managed and git namespaces”](#managed-and-git-namespaces) A namespace gets its files from one of two sources. | | Managed | Git | | --------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | | Created by | An editor, with **Create namespace**, the API or `sluice namespaces push --create`. | A mapping of a git source. See [Sync a namespace from git](/how-to/sync-a-namespace-from-git/). | | Files change by | A save in the editor, an upload, a revert, `sluice namespaces push` or the assistant. | A commit on the tracked branch and a sync. | | Edit in Sluice | Yes. Each save makes a new version. | No. The namespace is read-only. An edit goes to a new branch with **Push to branch**. | | Version label | A number: `v1`, `v2`, … | The commit SHA. | | Badge | **Managed** | **Git** and **Read-only** | A file write to a git namespace returns 409 `namespace_read_only`. This keeps the repository as the only source of the files. ## Every change is a version [Section titled “Every change is a version”](#every-change-is-a-version) A version, also called a snapshot, is one fixed set of files. Sluice never changes a version. A save creates a new version and makes it the head of the namespace. A managed version records a message and the user who made it. A git version records the commit SHA, and its message is `git : `. A save or sync that changes no file makes no version. Versions matter in three places: * **Executions.** A new execution pins the head version. It runs with those files to the end, even when a later save changes them. See [Executions and states](/concepts/executions-and-states/). * **Flow revisions.** When a version changes a flow file, or changes the validation result of a flow, Sluice stores a new revision of that flow. The **Revisions** tab of the flow lists the revisions and compares two of them. * **History.** The **Versions** tab of a namespace lists the versions with author, message and time. ### Compare and revert [Section titled “Compare and revert”](#compare-and-revert) The **Versions** tab has **Compare versions**. Pick a **From** and a **To** version to see a unified diff of each added, removed and modified file. Binary files show no diff. The API is `GET /api/v1/namespaces/{namespace}/diff?from=1&to=2`. **Revert to this version** on an old version creates a new version with the files of the old one. The message is `Revert to version ` unless you give another. History stays complete: the versions after the old one remain. Revert exists only for managed namespaces. For a git namespace, revert the commit in the repository. ## Namespace defaults [Section titled “Namespace defaults”](#namespace-defaults) `namespace.yaml` at the namespace root sets defaults for every flow of the namespace. The file is optional. ```yaml description: ELT pipelines defaults: executor: { type: kubernetes, pool: cluster-a, image: ghcr.io/acme/elt:1.4.0 } env: { TZ: Europe/Zurich } retry: { max_attempts: 2 } timeout: 1h ``` | Field | How a flow overrides it | | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `defaults.executor` | Field by field: the task executor, then the flow executor, then these defaults, then the instance default `process`. | | `defaults.env` | The flow `env` and the task `env` override it key by key. | | `defaults.retry` | The flow `retry` and the task `retry` override it field by field. | | `defaults.timeout` | The default task timeout. A task `timeout` overrides it. Without either, a task has 24 hours. | A new execution stores the flow merged with the defaults of its version. A later change to `namespace.yaml` does not change it. Sluice validates `namespace.yaml` like a flow file. An invalid `namespace.yaml` gives no defaults. ## Ways to change files [Section titled “Ways to change files”](#ways-to-change-files) ### The namespace editor [Section titled “The namespace editor”](#the-namespace-editor) The **Files** tab of a managed namespace is an editor. It holds a file browser on the left and a code editor on the right. ![The Files tab of the namespace sales: the file browser with five files, and the editor with nightly-load.flow.yaml open.](/_astro/editor-light.C2e9rSSu_2qs1DR.webp)![The Files tab of the namespace sales: the file browser with five files, and the editor with nightly-load.flow.yaml open.](/_astro/editor-dark.BHfXrbQv_26oJ5A.webp) The editor stages your edits in the browser. An edited file shows a mark in the file browser, and the editor shows **Unsaved changes** with a **Discard** action. Edit several files, then click **Save changes** under the file browser. The dialog lists the staged files and asks for a **Commit message**. All staged files go into one version. **Save** in the editor toolbar, or Cmd+S or Ctrl+S, saves the open file as its own version. While you type in a flow file or in `namespace.yaml`, the server validates the text. The editor lists each error with its line, column, code and message. A save sends the version that the editor loaded. When another user saved first, the save returns 409 `version_conflict`, and the dialog offers **Reload**. Your staged files stay in the dialog until you reload. A script file has **Run** in the editor toolbar, or Cmd+Enter or Ctrl+Enter. It runs the file of the head version as a `file` execution. ### The CLI [Section titled “The CLI”](#the-cli) `sluice namespaces push` uploads a local directory as one new version. It compares the directory with the head version and sends only the added, changed and removed files. When nothing differs, it creates no version. ```sh sluice namespaces push ./namespace --namespace sales --message "Update the extract script" sluice namespaces push ./namespace --namespace sales --create ``` | Behaviour | Detail | | --------------- | ------------------------------------------------------------------------------------------------ | | Target | `--namespace`, or the name of the directory. | | New namespace | `--create` creates a managed namespace when it does not exist. Without it, the command fails. | | Removed files | The new version drops each file that the directory does not have. | | Skipped entries | `.git` directories, symbolic links and invalid paths. The command prints a warning for each. | | Executable bit | The command keeps the executable bit of each local file. | | Conflict | The command sends the head version that it read. A save in between gives 409 `version_conflict`. | Run `sluice validate` on the directory first. The push does not stop for an invalid flow: the server stores it, marks it invalid and deactivates its triggers. ### The assistant [Section titled “The assistant”](#the-assistant) The assistant can propose and apply file changes. It validates each proposal and refuses to apply an invalid flow. After you confirm, the change becomes a new version of a managed namespace, or a new branch of a git namespace. See [Set up the assistant](/how-to/set-up-the-assistant/). ## Delete a namespace [Section titled “Delete a namespace”](#delete-a-namespace) An admin can delete a managed namespace. The delete fails with 409 `executions_running` while an execution of the namespace runs. The delete also removes the flows of the namespace and stops their triggers. Old executions stay visible. ## Next steps [Section titled “Next steps”](#next-steps) [Sync a namespace from git](/how-to/sync-a-namespace-from-git/) [Use secrets and variables](/how-to/use-secrets-and-variables/) [Flows, tasks and templates](/concepts/flows-and-tasks/) [Run flows from GitHub Actions](/how-to/run-flows-from-github-actions/) # Security model > How Sluice authenticates users, tokens and runners, what each role can do, and how it protects sessions, secrets and the audit trail. This page explains how Sluice decides who can do what, and how it keeps secrets out of places where they do not belong. For the steps to secure a deployment, see [Harden a deployment](/operations/harden-a-deployment/). ## Credentials [Section titled “Credentials”](#credentials) Sluice has its own user accounts. It has no OIDC, SSO or SCIM. A request authenticates in one of three ways: | Credential | Where it works | Used by | | ---------------------------------------- | --------------------- | -------------------------------------- | | Session cookie `sluice_session` | The UI and `/api/v1` | Browsers | | API token, `Authorization: Bearer slu_…` | `/api/v1` and `/mcp` | Scripts, CI jobs, the CLI, MCP clients | | Run token, `Authorization: Bearer …` | `/api/runner/v1` only | The runner inside a task | Webhooks under `/hooks/` use their own keys or signatures. `/mcp` accepts only API tokens. A session cookie there gets `401`. ### Passwords [Section titled “Passwords”](#passwords) Sluice stores each password as an argon2id hash with 19 MiB of memory, 2 iterations and 1 thread. A new password needs at least 10 characters. A change of your own password needs the current password, and it ends your other sessions. An admin can set a temporary password for a user. Until the user sets a new one, every operation except the own profile and password operations answers `403 password_change_required`. The login has a rate limit. More than 10 failures for one email, or 50 failures from one IP address, in 15 minutes give `429 rate_limited` with `Retry-After`. The counts are in Postgres, so the limit applies over all instances. Sluice reads the IP address from the TCP connection, not from `X-Forwarded-For`. Behind a reverse proxy, all clients thus share the address of the proxy. ### Sessions [Section titled “Sessions”](#sessions) | Property | Value | | ------------- | ----------------------------------------------------------------------------------------------------- | | Cookie | `sluice_session`, `HttpOnly`, `SameSite=Lax`, `Path=/`. | | `Secure` flag | On when `SLUICE_PUBLIC_URL` starts with `https://`. | | Lifetime | `SLUICE_SESSION_TTL`, default `168h`. Each use extends it, at most once per minute. | | Storage | Postgres holds only the SHA-256 hash of the session ID. | | End | Logout deletes the session. A disable or a password reset of a user deletes all sessions of the user. | ### API tokens [Section titled “API tokens”](#api-tokens) An API token is `slu_` and 43 base62 characters, 256 random bits. Sluice shows the token once, at creation. The list shows only the first 10 characters, for example `slu_w6Ryj9`. Postgres holds only the SHA-256 hash. A token has a role that is at most the role of its owner. Its effective role is the lower of the token role and the current role of the owner. A demoted owner thus does not keep a higher token. A token can expire after 1 to 365 days, or never. A revoked or expired token, or a token of a disabled user, gets `401`. Each request reads the session or the token from Postgres. A disable, a role change or a revoke thus applies on all instances at once. ## Roles [Section titled “Roles”](#roles) Sluice has four fixed roles: `viewer`, `operator`, `editor` and `admin`. Each role has all permissions of the roles before it. | Capability | viewer | operator | editor | admin | | ------------------------------------------------------------------------------- | ------ | -------- | ------ | ----- | | Read dashboards, flows, files, executions, logs, metrics and variables | ✓ | ✓ | ✓ | ✓ | | List secret keys and their metadata, never values | ✓ | ✓ | ✓ | ✓ | | Use the assistant with the read tools | ✓ | ✓ | ✓ | ✓ | | Trigger, cancel, rerun and restart executions, run a file | | ✓ | ✓ | ✓ | | Request a triage, git **Sync now** | | ✓ | ✓ | ✓ | | Edit managed files, push a branch, enable or disable flows, rotate webhook keys | | | ✓ | ✓ | | Write namespace secrets and variables, check a secret | | | ✓ | ✓ | | Create managed namespaces | | | ✓ | ✓ | | Users, all tokens, global secrets and variables, secret providers, git sources | | | | ✓ | | Storage, instances, the AI provider, the audit log, delete namespaces | | | | ✓ | Every user manages their own profile, password and tokens. Every API operation declares its access in code. The server does not start when an operation has none. The server checks the role before it reads the request body, so a caller without permission never sees validation details. The UI hides actions that your role does not allow, but the server enforces each rule. ## The same-origin rule [Section titled “The same-origin rule”](#the-same-origin-rule) A browser sends the session cookie with every request to Sluice, also from another site. Sluice therefore checks the origin of each change that uses the cookie. A `POST`, `PUT`, `PATCH` or `DELETE` with the cookie passes only in one of these cases: * `Origin` is the origin of `SLUICE_PUBLIC_URL`. * `Origin` has the same host as the `Host` header of the request. * The request has no `Origin`, and `Sec-Fetch-Site` is `same-origin`. Every other cookie request gets `403 csrf_failed`, also `Origin: null`. Requests with an API token do not need `Origin`, because a browser does not add a token by itself. ## Security headers [Section titled “Security headers”](#security-headers) Every response of the UI and of the API has these headers: | Header | Value | | ------------------------- | -------------------------------------------- | | `Content-Security-Policy` | `default-src 'self'; frame-ancestors 'none'` | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` | `strict-origin-when-cross-origin` | The UI loads scripts, styles, fonts and images only from its own origin. The fonts are in the binary. No other site can show Sluice in a frame. A reverse proxy must not remove or change these headers. ## Secrets [Section titled “Secrets”](#secrets) The rule is that no secret value leaves the task process in plain text. | Place | Protection | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | Postgres | Builtin secret values are AES-256-GCM ciphertext. External secrets store only the reference. | | API responses | No operation returns a secret value. A secret check returns only a status. | | Logs, outputs, metric tags, errors | The runner masks secret values before it sends them. The server masks them again before it stores them. A secret shows as `***`. | | Object storage | Log archives and artifacts hold only masked text. | | Docker containers and Kubernetes Jobs | They hold no secret values. The runner reads them from the runner API at run time. | | AI requests and tool results | Sluice masks execution data with the secret values of every task run of the execution. | | Audit events | Events hold keys and provider names, never values. | The task process itself gets the resolved values in its environment. Sluice cannot stop a script that sends a value somewhere else. ### Master keys [Section titled “Master keys”](#master-keys) `SLUICE_MASTER_KEYS` holds the keys that encrypt builtin secrets, as `kid:base64key` entries. Each key has 32 bytes. The first key is the active key for new writes. The other keys can still decrypt older values. Each stored value records the ID of its key. Without master keys, a write of a builtin secret answers `409 builtin_provider_disabled`. The other providers still work. When a stored value uses a key ID that is not in the list, `/readyz` fails with `master_key_missing`. `sluice secrets rekey` encrypts all builtin values again with the active key. See [Rotate the master key](/operations/rotate-the-master-key/). ### Stored hashes [Section titled “Stored hashes”](#stored-hashes) Postgres holds no credential in plain text. Passwords are argon2id hashes. Session IDs, API tokens, run tokens and webhook keys are SHA-256 hashes. ## Run tokens [Section titled “Run tokens”](#run-tokens) The dispatcher creates a run token when it claims a task run. The token reaches the runner in `SLUICE_RUN_TOKEN`. | Property | Value | | -------- | ---------------------------------------------------------------------- | | Scope | One task run. A token of task A on task B gets `403`. | | Validity | Only while the task run is `RUNNING`. | | Expiry | The task timeout plus 10 minutes. | | End | Sluice deletes the hash when the task run ends. Later calls get `401`. | | Routes | `/api/runner/v1` only. A run token cannot call `/api/v1`. | A Kubernetes Job holds the run token in its environment, because the runner needs it. The token has a narrow scope and expires, and the Job holds no secret values. ## Webhook keys [Section titled “Webhook keys”](#webhook-keys) A webhook trigger has no key until an editor rotates it. The rotation returns the key and the URL once. A key has 256 random bits. Sluice stores only its hash and compares it in constant time. A wrong key gets `404`, so a caller cannot find out which flows exist. Sluice does not store the `Authorization`, `Cookie` and `Proxy-Authorization` headers of a webhook call. Git webhooks use an HMAC signature or a token from a global secret. ## MCP clients [Section titled “MCP clients”](#mcp-clients) An MCP client uses an API token, and each tool runs with the role of the token. A mutating tool runs at once, with no confirmation, and Sluice records `ai.tool.call` in the audit log. Give each client its own token with the lowest role that it needs and an expiry. See [The assistant and MCP](/concepts/the-assistant-and-mcp/). ## Audit log [Section titled “Audit log”](#audit-log) Sluice records each change with the time, the actor type (`user`, `token`, `system` or `ai`), the actor, the action, the target, details and the client IP address. A call with an API token records the token ID. An admin reads the log on **Settings → Audit log** or with `GET /api/v1/audit`, with filters for the actor, the action, the target and the time. | Area | Actions | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Sign-in | `auth.login`, `auth.login_failed`, `auth.logout`, `auth.revoke_other_sessions` | | Users and tokens | `user.create`, `user.update`, `user.change_password`, `user.reset_password`, `token.create`, `token.revoke` | | Secrets and variables | `secret.create`, `secret.update`, `secret.delete`, `secret.rekey`, `secret_provider.create`, `secret_provider.update`, `secret_provider.delete`, `variable.create`, `variable.update`, `variable.delete` | | Git, namespaces and files | `git_source.create`, `git_source.update`, `git_source.delete`, `git_source.sync`, `git.push`, `namespace.create`, `namespace.delete`, `file.save`, `file.sync` | | Flows and triggers | `flow.enable`, `flow.disable`, `trigger.webhook_key_rotate`, `trigger.failed`, `trigger.chain_depth_exceeded` | | Executions | `execution.trigger`, `execution.run_file`, `execution.cancel`, `execution.rerun`, `execution.restart` | | AI | `ai.provider.update`, `ai.provider.delete`, `ai.triage.request`, `ai.action.confirmed`, `ai.action.rejected`, `ai.tool.call` | The maintenance leader deletes events older than 365 days. ## TLS and the public URL [Section titled “TLS and the public URL”](#tls-and-the-public-url) Sluice serves plain HTTP. It has no TLS settings. Put an Ingress, a load balancer or a reverse proxy with TLS in front of it. `SLUICE_PUBLIC_URL` must be the URL that browsers use. Sluice uses it for the `Secure` flag of the cookie, the same-origin check and the webhook URLs. Keep `SLUICE_INTERNAL_URL` on the internal network. Runners call it with run tokens. ## Containers [Section titled “Containers”](#containers) Both Sluice images run as the non-root user 65532. The Helm chart runs the server with a read-only root file system, no privilege escalation, all capabilities dropped and the seccomp profile `RuntimeDefault`. The only writable path is an `emptyDir` at `/tmp`. See [Deploy on Kubernetes with Helm](/how-to/deploy-on-kubernetes/). ## Related pages [Section titled “Related pages”](#related-pages) * [Harden a deployment](/operations/harden-a-deployment/) * [Rotate the master key](/operations/rotate-the-master-key/) * [Use secrets and variables](/how-to/use-secrets-and-variables/) * [The assistant and MCP](/concepts/the-assistant-and-mcp/) # The assistant and MCP > How the assistant in the web UI and the MCP server share one tool registry, and how roles, confirmation, masking, attachments and limits apply to each. This page explains the two ways an AI model works with Sluice. The assistant is a chat panel in the web UI. The MCP server at `/mcp` gives the same tools to an external client, for example a coding agent. Both use one tool registry, so a tool behaves the same in both places. ## One tool registry [Section titled “One tool registry”](#one-tool-registry) Each tool has a name, an input schema, a minimum role and a mutating flag. [MCP tools](/reference/mcp-tools/) lists all of them. | Group | Tools | Minimum role | | --------------- | ---------------------------------------------------------------------------------------------------------- | ------------ | | Read | `list_namespaces`, `list_flows`, `get_flow`, `list_files`, `read_file`, `validate_flow`, `get_flow_schema` | viewer | | Read executions | `list_executions`, `get_execution`, `get_logs`, `get_metrics`, `get_insight` | viewer | | Run | `trigger_execution`, `cancel_execution`, `rerun_execution`, `restart_execution` | operator | | Change files | `propose_change`, `apply_change` | editor | A tool is mutating when it changes Sluice: the run tools and `apply_change`. `propose_change` validates files and returns a diff, but it writes nothing. Only the assistant has `propose_change`. An MCP client uses `validate_flow` and then `apply_change`. A tool call always checks the role of the caller. A caller without the role gets the tool error `forbidden`. The assistant offers the model only the tools that your role allows. ## Two front ends [Section titled “Two front ends”](#two-front-ends) | | Assistant | MCP server | | ------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------- | | Where | The **Assistant** panel on every page of the web UI. | `/mcp`, streamable HTTP, stateless, JSON answers. | | Who calls the model | Sluice, with the configured AI provider. | The client, with its own model. Sluice needs no provider. | | Credential | Your session. | An API token. A session cookie gets `401`. | | Role | Your role. | The effective role of the token. | | Mutating tools | Wait for your **Confirm**. | Run at once. | | Audit | `ai.action.confirmed` or `ai.action.rejected`, and the event of the change itself. | `ai.tool.call`, and the event of the change itself. | `/.well-known/mcp.json` describes the MCP server for discovery. It names the endpoint and the `Authorization` header, and holds no data. ## Confirm before a change [Section titled “Confirm before a change”](#confirm-before-a-change) The assistant never changes Sluice on its own. When the model calls a mutating tool, the call does not run. The panel shows it with **Confirm** and **Reject**: * **Confirm** runs the tool as you, with your role. The change has you as its actor. * **Reject** tells the model that you refused. The model continues the turn with this fact. * While an action waits, you cannot send a new message. An MCP client has no such step. Sluice cannot ask a person inside an MCP call, so a mutating tool runs when the client calls it. The client can ask its own user first. Control an MCP client with its token: give it the lowest role that it needs, and an expiry. A viewer token gets only the read tools. ## Masking [Section titled “Masking”](#masking) Execution data can hold secret values, for example in a log line. The tools `get_execution`, `get_logs`, `get_metrics` and `get_insight` mask their results with the secret values of every task run of the execution. A secret shows as `***`. The `grep` of `get_logs` searches the masked text, so a secret value never matches. The same masking applies to the triage context and to attachments. No model request and no MCP result holds a secret value that Sluice knows. ## Attachments [Section titled “Attachments”](#attachments) In the assistant, type `@` to attach a flow, a recent execution or a namespace file to a message. A message holds at most 5 attachments. Sluice reads each attachment with the read tools, as you. Your role, the masking and the size limit of the tools thus apply. A message with an object that you cannot read fails. | Attachment | Content for the model | | ---------- | ----------------------------------------------------------------------------------------- | | Flow | `get_flow`: the flow with its YAML source. | | File | `read_file`: the file of the head version of the namespace. | | Execution | `get_execution`, `get_insight`, and `get_logs` with `failed_only` and the last 100 lines. | **Fix with assistant** on a failed execution starts a new conversation with the execution attached. See [Triage a failed execution](/how-to/triage-a-failed-execution/). ## Flow authoring [Section titled “Flow authoring”](#flow-authoring) When you ask the assistant for a flow change, it calls `propose_change`. The panel shows the diff of each file and the validation issues. When the proposal is invalid, the model gets the issues and can try again. When it is valid, the model calls `apply_change`, and you confirm. | Namespace source | Result of `apply_change` | | ---------------- | ---------------------------------------------------------------------------------------------------- | | Managed | A new version of the namespace. | | Git | A new branch `sluice//