-
Notifications
You must be signed in to change notification settings - Fork 0
Stores
stores: is a named map (like hosts:/agents:) of data stores a workflow reads and writes —
durable state that outlives a single run: a "seen this incident id" gate, a rolling counter, a list
of pending items, a row in your analytics DB. Two families:
-
KV types —
boltdb,redis,http— served by thekv.*verbs. -
SQL types —
postgres,mysql,sqlite— served bysql.query/sql.exec.
Everything is explicit: there is no default store, and a data verb reaches a store only through
its required store: selector. A config with no stores: section has no stores. The selector is
family-checked at load — a kv.* verb pointed at a SQL store (or a sql.* verb at a KV store)
fails conductor validate, naming the store and its type.
stores:
scratch: { type: boltdb } # file <data dir>/scratch.db
archive: { type: boltdb, path: /mnt/big/archive.db }
cache: { type: redis, url: "redis://10.0.0.5:6379/0", password: '{{ vault "house" "redis_pw" }}' }
shared: { type: http, base_url: https://kv.example.com/kv, auth: { type: bearer, token: '{{ vault "house" "kv" }}' } }
analytics: { type: postgres, url: "postgres://conductor@db/analytics", password: '{{ vault "house" "pg" }}' }
billing: { type: mysql, dsn: "conductor:@tcp(db:3306)/billing", password: '{{ vault "house" "mysql" }}' }
local: { type: sqlite } # file <data dir>/local.sqliteA stores: entry that names an unknown type, misses its connection fields, or (boltdb/sqlite) can't
open its file is a load error naming the store. Secrets in connection fields use the usual
{{ vault … }} / ${ENV} schemes (Secrets).
| type | connection | notes |
|---|---|---|
boltdb |
path? |
one file per store: path:, else <data dir>/<store-name>.db (beside the state file). Pure Go, ACID, fsync on commit — committed state survives a crash and the daemon's auto-update restart. The zero-config default for local durable state |
redis |
url (redis://host:port/db), password?
|
native ops (SET/GET/SETNX, RPUSH, LPOP/RPOP, LRANGE, LREM, PEXPIRE for ttl); multi-step read-modify-writes run as Lua scripts (WATCH/MULTI) to keep single-transaction atomicity. Use when several conductors (or other apps) share state |
http |
base_url, auth? (bearer/basic/header/oauth2) |
a generic REST shim — protocol below; the remote shim owns atomicity for read-modify-write ops |
Every KV backend implements one KVBackend interface with identical semantics; a backend that can't
serve an op is capability-checked rather than silently degrading. Native hosted-KV SDKs (firestore,
dynamodb) are the documented extension point: implement KVBackend, add a store-builder entry.
store: is required on every verb and must be a literal name of a defined store — a missing,
templated, or undefined store: fails conductor validate, not the run.
| verb | options (beyond store) |
output |
|---|---|---|
kv.get |
key, namespace?, default?
|
{ value, found } (found=false → value is default, else null) |
kv.set |
key, value, namespace?, ttl?
|
{} |
kv.setnx |
key, value, namespace?, ttl?
|
{ value, created } — set only if absent; created=false returns the existing value |
kv.merge |
key, value (object), namespace?
|
{ value } — shallow-merge into the object at key (upsert) |
kv.delete |
key, namespace?
|
{} |
kv.incr |
key, by? (default 1), namespace?
|
{ value } |
kv.append |
key, item | items, unique?, namespace?
|
{ value, len } — append to the list at key (created as []); unique skips present values |
kv.remove |
key, item | items, namespace?
|
{ value, len } — remove all occurrences (absent = no-op) |
kv.contains |
key, item, namespace?
|
{ contains } (false when absent) |
kv.first / kv.last
|
key, namespace?
|
{ value, found } |
kv.index |
key, index, namespace?
|
{ value, found } — negative counts from the end; out of range → found=false |
kv.slice |
key, start?, end? (exclusive), namespace?
|
{ value, len } — Python-style, negatives allowed, bounds clamp |
kv.len |
key, namespace?
|
{ len } (0 when absent) |
kv.pop |
key, from? (front|back, default back), namespace?
|
{ value, found, len } — remove and return an end element; empty/absent → found=false, no error |
kv.list |
namespace?, prefix?
|
{ keys, entries } |
-
Namespaces isolate keyspaces (boltdb buckets / redis key prefixes), auto-created on write;
namespace:defaults todefault. Values are JSON — any serializable value round-trips. -
Atomicity. Every read-modify-write verb (
incr,setnx,merge,append,remove,pop) is one backend transaction, so concurrent and grouped steps hitting the same key stay correct (parallel pops each take a distinct element).get/first/last/index/slice/len/containsare read-only. -
TTL.
ttl:onset/setnxexpires the key: an expired key reads as absent and is skipped bylist(boltdb sweeps in the background; redis expiry is native). Mutating a live entry keeps its expiry. -
Type errors are step errors naming the key and the actual type (
mergeon a non-object; the list verbs on a non-list).
stores:
state: { type: boltdb }
triggers:
- name: new-incidents
on: [ pd.incident ]
steps:
- { id: gate, uses: kv.get, options: { store: state, namespace: pagerduty, key: last-seen, default: "" } }
- if: "{{ .incident.id }} != {{ .gate.value }}"
uses: slack-ops.post
options: { channel: "#outages", text: "New incident {{ .incident.id }}" }
- { uses: kv.set, options: { store: state, namespace: pagerduty, key: last-seen, value: "{{ .incident.id }}" } }An http store POSTs every operation to base_url as one JSON object and reads the verb's result
object back:
POST <base_url>
{ "op": "get|set|setnx|merge|delete|incr|append|remove|contains|
first|last|index|slice|len|pop|list",
"namespace": "…", "key": "…",
"value": …, set/setnx (any JSON), merge (object)
"items": […], "item": …, append/remove; contains
"unique": bool, "by": int, append; incr
"ttl_ms": int, set/setnx
"index": int, index
"start": int, "end": int, "end_set": bool, slice
"front": bool, pop
"prefix": "…" } list
200 → the verb's result JSON ({value, found}, {value, created}, {value, len},
{contains}, {len}, {value, found, len}, {keys, entries})
non-2xx → the op fails; the body's {"error": "…"} becomes the message
Conductor sends exactly one request per operation and never composes multi-request transactions — the shim must apply each read-modify-write op transactionally on its side to keep the atomicity contract.
SQL store types run statements against your existing schema — conductor does not create tables or manage migrations. The drivers are pure Go (pgx, go-sql-driver/mysql, modernc.org/sqlite), keeping the static-binary invariant.
| type | connection | notes |
|---|---|---|
postgres |
url (postgres://user@host/db), password? (overrides the URL's) |
pgx; URL validated at load, dialed lazily; placeholders $1, $2, … |
mysql |
dsn (user:pass@tcp(host:3306)/db), password?
|
DSN validated at load, dialed lazily; placeholders ?
|
sqlite |
path? |
one file per store: path:, else <data dir>/<store-name>.sqlite; :memory: works; opened at load like boltdb; placeholders ?
|
SQL stores also take code_access: none | read | write — what in-process Code-Steps
may do through ctx.sql: read (query-only, the default), write opts a store into exec from code,
none cuts code steps off. The sql.* verbs are config-authored and not gated. sqlite stores
refuse ATTACH/DETACH/PRAGMA/VACUUM for every caller (they reach the host filesystem/engine,
not your schema).
store: is required and must name a SQL-type store.
| verb | options | output |
|---|---|---|
sql.query |
store, sql, args?
|
{ rows, count } — rows is one {column: value} object per row |
sql.exec |
store, sql, args?
|
{ rows_affected, last_insert_id? } — last_insert_id is absent on postgres (use RETURNING with sql.query) |
Parameterized, never interpolated. The sql: text is fixed config; event data goes in args:,
which bind to the driver's placeholders in order. A value containing quotes or '; DROP TABLE …; --
is stored and returned as that literal string — never parsed as SQL. Results are JSON-shaped: byte
columns decode to strings, timestamps to RFC 3339, NULL to null.
stores:
analytics: { type: postgres, url: "postgres://conductor@db/analytics", password: '{{ vault "house" "pg" }}' }
triggers:
- name: record-incidents
on: [ pd.incident ]
steps:
- id: record
uses: sql.exec
options:
store: analytics
sql: "INSERT INTO incidents (id, urgency, title) VALUES ($1, $2, $3)"
args: [ "{{.incident.id}}", "{{.incident.urgency}}", "{{.title}}" ]
- id: recent
uses: sql.query
options:
store: analytics
sql: "SELECT id, title FROM incidents WHERE urgency = $1 ORDER BY created_at DESC LIMIT 5"
args: [ high ]
- uses: slack-ops.post
options: { channel: "#outages", text: "{{.recent.count}} recent high-urgency incidents" }Three access paths reach the same stores — see Code-Steps for the full ctx surface:
-
Verbs —
uses: kv.*/sql.*in steps and hooks (tables above), audited like any verb call. -
Templates (read-only, store first):
{{ kv "cache" "runs" (print .pr) | default 0 }}and{{ kvContains "cache" "pd" "seen" .incident.id }}. The template surface never mutates. -
ctx.store("<name>")/ctx.sql("<name>")inrun:code — the engine plugins (js, lua, risor, go-embed) and a localuse: clistep resolve a defined store to a handle with the full method set; every op is authorized host-side. Host-interpreter steps (run: sh/node/python/…) run in a separate process — they use thekv.*/sql.*verbs.
- use: js # an engine plugin; `use: cli` reaches the same faces
code: |
const kv = ctx.store("cache");
const key = "last-invoice-" + ctx.inputs.contact_id;
const prev = kv.get("billing", key); // (namespace, key) — absent reads null
kv.set("billing", key, ctx.recent.invoices[0].InvoiceID);
return { first_time: !prev };kv.list and sql.query return at most 1000 items by default. Pass limit: to ask for a
different bound; the result carries truncated: true when more matched than were returned. The
default exists because neither call has an inherent bound and both results cross into an agent's
context — an uncapped SELECT * FROM events against a large table would otherwise be handed to
the model whole. Check truncated before treating a result as the complete set.
A namespace partitions keys within a store; it is not a tenant wall. Anything that can reach
the store can reach every namespace in it: the kv.*/sql.* verbs take the namespace as a plain
option, so a grant for one namespace is a grant for all of them.
The boundary that is enforced is the store: a policy.agent_authored.verbs entry's store:
list (kv.*: {store: [cache]}, or code: {store: [cache]} for a run: code step's ctx.kv/ctx.sql)
gates which stores an agent-authored step may touch at all, deny-by-default,
and a skill grant can pin one agent tighter still with kv.*: { store: [...] }. If two
workloads must not see each other's data, give them separate stores — not separate namespaces
in one store.
-
Code-Steps — the
ctxsurface (ctx.store,ctx.sql,ctx.memory) in each engine -
Verbs — how
uses:verbs and their options work -
Memory — the agent-facing durable store (
memory.*), distinct from these data stores -
Secrets —
{{ vault … }}for store connection credentials - Configuration — the full trigger/step grammar
Setup
The model
- Connectors
- Workflows
- Reuse
- Settings-and-Templating
- Packs
- Verbs
- Code-Steps
- Stores
- Runtimes
- Model-Selection
- Model-Discovery
- Steps
- Decide-Steps
- Grouping
- Memory
- Binary-Data
- Agent-Skill
- Policy
- Gates
- Teams
- Outcomes
- Cost-Accounting
- Secrets
- Hosts
- Isolation
- Trust-and-Isolation
Connectors
Operations
- One-Shot
- Callable-Service
- Runs
- Hand-offs
- Notifications
- Migration
- Controllers (legacy name → Runtimes)