Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Parameters & pipeline templates

Most fleets run the same pipeline shape over and over with a handful of values that change: a tenant id, a date window, a target table, a source URL. Copying the config once per tenant means N files to keep in sync; re-sending the whole config on every trigger means re-validating it every time and hoping the caller got the shape right.

faucet splits that into two pieces:

  • params: — a config declares its typed, trigger-time surface. Available everywhere, no build feature needed.
  • the template registry — register a parameterized config once, then trigger runs by {id, params}. Needs the templates build feature.

Declaring parameters

version: 1
name: tenant-sync

params:
  tenant_id:
    type: string
    required: true
    description: Tenant whose events to sync
  since:
    type: string
    default: "1970-01-01"
  page_size:
    type: int
    default: 500
  api_token:
    type: string
    required: true
    secret: true

pipeline:
  source:
    type: rest
    config:
      url: "https://api.example.com/tenants/${param.tenant_id}/events?since=${param.since}"
      auth: { type: bearer, config: { token: "${param.api_token}" } }
      pagination:
        type: page_number
        config: { page_param: page, size_param: per_page, size: "${param.page_size}" }
  sink:
    type: jsonl
    config:
      path: "./out/${param.tenant_id}/events.jsonl"

Fields per entry:

FieldMeaning
typestring (default) · int · float · bool
requiredThe caller must supply a value. Mutually exclusive with default.
defaultValue when the caller supplies none. An ordinary config scalar, so default: "${env:SINCE}" works.
secretRegistered for redaction the instant it is bound — never reaches a log, an error message, an API response, the audit log, or the registry.
descriptionShown by faucet template list / show, GET /v1/templates, and the MCP get_template tool.

Reference a param anywhere in the config as ${param.NAME}. Write $${param.x} for a literal ${param.x}.

Types are real

When ${param.NAME} is a scalar’s entire text, the declared type survives: page_size above arrives at the connector as the JSON number 500, not "500". Embedded in a longer string (.../events?since=${param.since}) it is stringified, like every other interpolation namespace.

Values are accepted in either wire shape, so a CLI --param page_size=500 and an HTTP {"page_size": 500} behave identically — and --param page_size=abc is rejected up front, naming the param.

Running a parameterized config directly

faucet run and faucet validate take --param:

faucet run tenant-sync.yaml --param tenant_id=acme --param since=2026-01-01 \
  --param api_token="$TOKEN"

# Validate in CI without inventing values: required params bind to type-shaped
# placeholders, so the config's structure is still fully checked.
faucet validate tenant-sync.yaml

# Or check one concrete invocation end to end (strict binding).
faucet validate tenant-sync.yaml --param tenant_id=acme --param api_token=x

--param-env NAME=VALUE overrides an environment variable for that run’s ${env:VAR} resolution only; bare --param-env TOKEN takes the value from your own environment, so a secret never appears in the process arguments. The process environment itself is never modified — which is what makes this safe inside a concurrent server.

faucet run tenant-sync.yaml --param tenant_id=acme --param-env API_HOST=eu.example.com

Registering a template

faucet template register tenant-sync.yaml --store sqlite:./faucet-templates.db
# registered template 'tenant-sync' version 1
#
# params:
#   api_token            string  required  [secret]
#   page_size            int     default 500
#   since                string  default "1970-01-01"
#   tenant_id            string  required  — Tenant whose events to sync

faucet template list  --store sqlite:./faucet-templates.db
faucet template show  tenant-sync --store sqlite:./faucet-templates.db
faucet template run   tenant-sync --store sqlite:./faucet-templates.db \
  --param tenant_id=acme --param api_token="$TOKEN"
faucet template delete tenant-sync --store sqlite:./faucet-templates.db --version 1

--store accepts sqlite:<path>, a postgres://… URL, or memory (process-lifetime only, for a smoke test) — the same grammar as catalog.url and faucet serve --history, and it can be set once via FAUCET_TEMPLATE_STORE. SQL stores need the matching serve-history-sqlite / serve-history-postgres build feature.

faucet template run materializes the template and then runs it through the identical path as faucet run — observability, lineage, notifications, the catalog, SLA evaluation and row selection all behave the same.

Versions, launching, and channels

Every register appends a new numeric version, auto-incrementing from 1 — and that is all it does. Registering never moves existing callers. A nightly build, a feature branch, a half-tested experiment: they all land as a new version while everyone who did not pin one keeps running exactly what they ran yesterday.

Making a version live is a separate, deliberate step: launch.

faucet template register tenant-sync.yaml          # v4 exists; nobody is affected
faucet template launch   tenant-sync               # v4 is live — this moves callers

That split is the whole point. A deploy can register freely; promoting a build to “what production runs” stays a decision somebody makes on purpose.

Template status

A template is in exactly one of three states, and the state is derived from what has actually happened, so it can never disagree with the registry:

StatusMeaning
draftRegistered but never launched — the work-in-progress state. An unpinned run is refused (there is no blessed version); explicit selectors still work, so a draft is fully testable.
launchedA version has been launched. Unpinned runs resolve to it.
deprecatedExplicitly retired. Unpinned runs still work — retiring must not hard-break callers — but every trigger warns and listings mark it. delete is the hard stop.
faucet template register tenant-sync.yaml --launch   # skip the draft stage
faucet template deprecate tenant-sync --reason "superseded by tenant-sync-v2"
faucet template deprecate tenant-sync --undo         # revive it

Channels

On top of the numbers sit named channels: pointers at one numeric version. Three are derived — computed from the launch log, never assigned:

Derived channelResolves to
stableThe launched version. The default when no version is given. Moves only via launch.
previousThe version launched before the current one — the rollback target. Unset until a second launch.
newestThe highest version number, launched or not. The build tip.

The rest are assignable — you point them wherever you like with promote:

Assignable channelMeaning
devDay-to-day development
testQA / integration testing
stagingStaging
pre-prodPre-production / release-candidate soak
canaryPartial-traffic canary ahead of prod
prodProduction

The set is closed on purpose: an open-ended tag namespace becomes a second, unreviewable naming system in which a typo (prd) silently creates a channel nobody watches. An unknown name is rejected with the valid list. Names are forgiving about spelling — pre-prod, pre_prod, PreProd, and preprod are one channel — and if you need a free-form label, put it in the run’s labels, not in the registry.

There is no latest. It reads as both “the newest build” and “the current stable release”, and those are exactly the two things this model keeps apart. Ask for it and faucet says so rather than guessing:

`latest` is not a version channel here because it is ambiguous. Did you mean
`stable` (the launched version — also the default when no version is given), or
`newest` (the highest version number, launched or not)?

Selecting a version

SelectorResolves to
(omitted)stable — the launched version
a channel name (prod, newest, previous, …)whatever that channel points at
a number (2)exactly that version
faucet template run tenant-sync --param tenant_id=acme            # stable
faucet template run tenant-sync --version prod    --param …       # whatever prod names
faucet template run tenant-sync --version newest  --param …       # the build tip
faucet template run tenant-sync --version 2       --param …       # pinned

Asking for an unset channel is an error phrased for that channel, because the fix differs: stable needs a launch, previous needs a second launch, an environment channel needs a promote. Silently falling back would run the wrong code.

Promoting and launching

# Register v5 and point `dev` at it in one step.
faucet template register tenant-sync.yaml --tag dev

# Walk it up the channels — each promote copies another channel's current target.
faucet template promote tenant-sync --tag test     --version dev
faucet template promote tenant-sync --tag pre-prod --version test
faucet template promote tenant-sync --tag prod     --version pre-prod
# → template 'tenant-sync': prod → v5

# Bless whatever soaked in pre-prod as the new stable.
faucet template launch tenant-sync --version pre-prod
# → template 'tenant-sync': launched v5 (was v4; previous → v4)

launch defaults to newest, since launching what you just registered is the common case. Re-launching the already-live version is a no-op — which is what keeps previous a real rollback target rather than a copy of the current version. A promote from a channel resolves to a concrete version at that moment, so a pointer never silently follows future registrations. Derived channels cannot be assigned: faucet template promote … --tag stable is rejected and tells you to use launch.

Rolling back

faucet template rollback tenant-sync
# → template 'tenant-sync': rolled back to v4 (was v5; previous → v5)

Rollback re-launches previous, and it is an ordinary launch under the hood — so the launch log keeps the full audit trail and previous becomes the version you just rolled off (roll back twice and you are where you started).

curl -sX POST localhost:8080/v1/templates/tenant-sync/launch \
  -H "Authorization: Bearer $TOKEN" -d '{"version":"pre-prod"}'
# → 200 {"id":"tenant-sync","version":5,"replaced":4,"already_launched":false,"status":"launched"}

curl -sX POST localhost:8080/v1/templates/tenant-sync/rollback \
  -H "Authorization: Bearer $TOKEN" -d '{}'

Inspecting

faucet template list shows one row per id — its status, what is live, and the build tip:

ID                          STATUS       LIVE    NEWEST   PARAMS  DESCRIPTION
orders-export               launched     v2      v2            4  Nightly export of an orders table.

faucet template show reports one version in the context of the whole release state — every version with the channels pointing at it, and who launched what:

template  orders-export   [launched]
name      orders-export
about     Nightly export of an orders table.
created   2026-08-07T14:34:05Z
showing   v2  (live)

versions:
  v2    live, newest, dev, staging
  v1    previous

launch history (newest first):
  v2    2026-08-07T14:34:05Z
  v1    2026-08-07T14:34:05Z

A description describes the template, so it carries forward: re-registering without --description keeps the previous one rather than blanking the listing.

GET /v1/templates/{id} returns the same picture as JSON, so a client can pin, promote, launch, or roll back without a second call:

{
  "id": "tenant-sync", "version": 2,      // the version returned
  "status": "launched",                   // draft | launched | deprecated
  "versions": [3, 2, 1],                  // everything stored, newest first
  "stable": 2,                            // the launched version (unpinned runs)
  "previous": 1,                          // the rollback target
  "newest": 3,                            // the build tip
  "is_stable": true,                      // the returned version is the live one
  "tags": { "dev": 3, "prod": 1 },        // assignable channels only
  "launches": [ { "seq": 2, "version": 2, "launched_at": "…", "launched_by": "ci" } ],
  "body": "version: 1\nname: tenant-sync\n…"
}

Pass ?version=newest to open a draft template — it has no stable version yet.

In the console

The web console (serve-ui) has a Templates view built around exactly this: a list showing each template’s status, live version, and build tip, and a per-template versions page with one row per version, the channels currently pointing at it, an assign-channel dropdown, and Launch / Config / Delete — plus Roll back, Deprecate, and a typed trigger form generated from the template’s params:.

Wire shapes and cleanup

version accepts a channel name ("prod"), a numeric string ("2"), or a bare number (2), so a query string, a JSON body, and an MCP tool argument all mean the same thing. 0, latest, and unknown names are rejected rather than silently falling back.

Deleting: --version <N|channel> removes one version; faucet template delete <id> with no --version removes the template entirely. Channels pointing at a deleted version — and its launch-log entries — are dropped with it, so no pointer outlives its target. Runs already produced are untouched.

The 20 most recent versions of each id are kept, so a template re-registered on every deploy keeps a useful rollback window without growing without bound.

Practical pattern. Let deploys register --tag dev freely — nothing moves. Walk a version up the channels (devtestpre-prodprod) as it earns trust. Bless it with launch when it should become what unpinned callers get, and keep rollback one command away. Point scheduled jobs at a channel (--version prod) when they must be pinned to a specific promotion train, and leave everything else unpinned so launch is your single release lever.

Triggering over HTTP

Point faucet serve --history at the same store and the same templates become triggerable over HTTP and MCP — one registry, not three:

faucet serve --history sqlite:./faucet-templates.db --auth-token "$TOKEN"
# Register (operator+ / TemplateWrite)
curl -sX POST localhost:8080/v1/templates \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"id":"tenant-sync","config":"'"$(sed 's/"/\\"/g;:a;N;$!ba;s/\n/\\n/g' tenant-sync.yaml)"'"}'

# Browse (viewer+ / TemplateRead)
curl -s localhost:8080/v1/templates            -H "Authorization: Bearer $TOKEN"
curl -s localhost:8080/v1/templates/tenant-sync -H "Authorization: Bearer $TOKEN"

# Trigger (operator+ / RunWrite)
curl -sX POST localhost:8080/v1/templates/tenant-sync/runs \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"params":{"tenant_id":"acme","api_token":"'"$TOKEN"'"},"env":{"API_HOST":"eu.example.com"}}'
# → 202 {"run_id":"…","status":"queued","template_id":"tenant-sync",
#        "template_version":1,"params":{"tenant_id":"acme","api_token":"***",…}}

A trigger is submitted through the same path as POST /v1/runs, so idempotency keys, doctor_first, queue limits, cluster dispatch, metrics, and the audit log all behave identically. The run is labelled template and template_version, so GET /v1/runs?… and your dashboards can group by provenance.

See the HTTP API reference for the full endpoint list.

Agent tools (MCP)

With --mcp, an agent gets list_templates / get_template read-only, plus register_template / run_template behind --mcp-allow-mutations and the caller’s RunWrite scope:

faucet serve --history sqlite:./faucet-templates.db --mcp --mcp-allow-mutations
# or, over stdio:
faucet mcp --template-store sqlite:./faucet-templates.db --allow-mutations

Without a store the template tools are not advertised at all, so an agent never sees a tool it cannot use.

What is and isn’t stored

The config body is stored verbatim. ${env:…} / ${vault:…} / ${secret:…} stay unresolved tokens and are resolved at trigger time, on the instance that runs the pipeline — the same privilege surface as any normally-submitted config. That is the recommended way to get a credential into a template: reference it from the body, don’t pass it as a param.

A caller-supplied secret: true param value is never persisted. It lives only for the duration of one trigger: bound into the materialized config, registered for redaction, and echoed back as "***".

One consequence is worth stating plainly: a clustered server persists the materialized config so a peer can execute the run, which would put a secret param value in the shared history database. A clustered trigger of a template declaring secret: true params is therefore refused with a 422 explaining the two safe alternatives (reference the secret from the body, or trigger on a non-clustered server). Non-clustered servers store no config body and are unaffected.

Safety properties

  • Structure safety. Params are substituted per JSON/YAML scalar, before the typed parse — a value containing :, a newline, or - stays the single scalar it replaced and can never inject a key or an array element. SQL-bound and JSON-safe substitution paths downstream are untouched, so the existing SQL/JSON-injection guarantees hold for param-derived text too.
  • No re-interpolation of caller input. Env/file/secret directives resolve before params bind, so a supplied value is never itself scanned for directives. A supplied value containing ${ is rejected outright: params are data, not directives.
  • Typos fail loudly. An undeclared --param, an undeclared ${param.x} reference, a missing required param, or a type mismatch is an error naming the param — never a silent no-op.
  • Nothing leaks by accident. ${param.*} binding happens pre-parse on every load path, and a token that somehow survived to matrix expansion is rejected there as a backstop rather than reaching a connector as literal text.

Build features

FeatureEnables
(none)The params: block, ${param.*}, --param / --param-env, faucet schema params
templatesfaucet template …, /v1/templates*, the MCP template tools (implies serve)
serve-history-sqlite / serve-history-postgresA registry that survives a restart

templates is in --features full, not in default.

See also