.sem files - saved pipe stages¶
A .sem file captures exactly one verb invocation in TOML. smartpipe run
stage.sem executes it; a shebang makes it directly executable. Composition
stays where it belongs - in the shell:
cat tickets.log \
| ./filter-urgent.sem \
| ./extract.sem > urgent.csv
A worked pair¶
filter-urgent.sem:
#!/usr/bin/env -S smartpipe run
verb = "filter"
prompt = "describes an urgent, customer-impacting problem"
extract.sem:
#!/usr/bin/env -S smartpipe run
verb = "map"
prompt = "Extract {customer, product, severity enum(low, medium, high)}"
output = "csv"
fields = ["severity", "customer", "product"]
Make them executable once (chmod +x *.sem), and each behaves exactly like the
command it stands for - stdin in, stdout out, same exit codes, same behavior.
The format¶
- The file is TOML. The shebang line is legal because
#opens a TOML comment. verbis required: any pipeline verb -map,extend,filter,where,embed,top_k,reduce,join,split,distinct,outliers,cluster,diff,summarize,sample,getschema,sort,chart. (runandconfigare refused - no recursion, no config mutation from scripts.)- Each verb accepts exactly the keys below - anything else is an error, and the error lists that verb's exact valid keys (the authoritative source).
The model verbs share these keys:
| Key | Type | Verbs | Becomes |
|---|---|---|---|
prompt |
string | map, extend, filter, reduce, join | the positional prompt |
prompt-file |
string | map, extend, filter, reduce, join | --prompt-file, resolved relative to the .sem file |
near |
string | top_k | --near |
k |
integer | top_k, join, cluster | top_k's positional K; --k elsewhere |
threshold |
number | top_k, join, distinct | --threshold |
not |
boolean | filter | --not |
right |
string | join, diff | --right, resolved relative to the .sem file |
model |
string | map, extend, filter, reduce, join, cluster, diff | --model |
embed-model |
string | embed, top_k, join, distinct, outliers, cluster, diff | --embed-model |
output |
string | map, extend, join | --output |
fields |
array of strings | map, extend, embed, top_k, reduce, join | --fields |
schema-file |
string | map, extend, reduce | --schema, resolved relative to the .sem file |
schema-from |
string | map, extend, reduce | --schema-from (the deterministic DSL) |
tally |
string | extend, reduce | --tally FIELD |
explode |
string | extend, reduce, cluster | --explode FIELD (one row per list element) |
group-by |
string | reduce | --group-by |
window / every |
integer | reduce | --window / --every |
verbose |
boolean | reduce | --verbose |
stream |
boolean | top_k | --stream |
top |
integer | cluster, diff | --top |
count |
integer | outliers, sample | the positional COUNT |
show-groups |
boolean | distinct | --show-groups |
all |
boolean | diff, getschema | --all |
allow-captions |
boolean | the media-converting verbs (filter, embed, top_k, reduce, join, distinct, outliers, cluster, diff) | --allow-captions |
concurrency |
integer | the model verbs | --concurrency |
max-calls |
integer | the model verbs | --max-calls |
in |
array of strings | the model verbs + split | repeated --in (globs resolve against the current directory, like the flag) |
from-files |
boolean | the model verbs + split | --from-files |
The free verbs take their natural keys: where takes predicate;
summarize takes expression; sample takes count and seed;
getschema takes all; sort takes by and desc; chart takes
field, facet, by-time, top, save, title; split takes by,
media, max-tokens.
Every verb also accepts strict-rows (boolean) - see
strict rows by default below.
Strict rows by default¶
A .sem file runs unattended, so it is strict about its rows: a mixed
record/text stream, or a row missing a field the stage needs, is an error
naming the offending row - not the stderr note an interactive pipe gets. The
first mixed row stops the run before anything downstream could spend on it:
error: input: line 4 is a plain text line in a record stream
--strict-rows demands one kind - declare it: --as jsonl (records) or --as lines (text).
Pipelines that mix kinds by design opt out with the strict-rows key -
per stage, or once at the top level of a pipeline file (a per-stage value
overrides the top-level one):
strict-rows = false # the whole pipeline tolerates mixing
[stage.keep]
verb = "sample"
count = 100
[stage.cut]
verb = "split"
strict-rows = false # or opt out one stage at a time
An explicit --strict-rows flag after the script, or a set
SMARTPIPE_STRICT_ROWS environment variable, still wins over the file's
opt-out - explicit always beats default. Interactive pipes (typing the verb
yourself) keep the permissive census note; the flip is .sem-scoped. The
full story of cuts, mixing, and the census lives in
the granularity ladder.
Unknown keys are errors - on purpose¶
config.toml ignores keys it doesn't know (a config must survive version
skew). A .sem script is the opposite trade: it runs unattended, so a typo'd
promt silently ignored would surface only later, in production. The
error names the key and lists the valid ones for that verb:
smartpipe run extract.sem
# → error: extract.sem: unknown key 'promt' - valid keys for map: as, concurrency, fields, from-files, in, max-calls, model, output, prompt, prompt-file, schema-file, schema-from, strict-rows
# → A .sem script runs unattended - a typo silently ignored would be a disaster.
# → Fix the key, then: smartpipe run extract.sem
Precedence¶
CLI flag > .sem value > environment > config file. Anything you pass
after the script overrides the file:
smartpipe run extract.sem --model ollama/qwen3:8b # wins over the file's model
The shebang¶
#!/usr/bin/env -S smartpipe run needs env -S (GNU coreutils ≥ 8.30, any
modern macOS). If your platform lacks it, the spelled-out form works everywhere:
smartpipe run extract.sem < cards.txt
Why one stage per file?¶
Multi-stage pipeline files were considered and rejected: they would re-implement
| - ordering, buffering, and error propagation the shell already does better.
One file = one stage keeps every .sem composable with everything else on your
system.
Pipelines: several stages in one file¶
A .sem file can hold a whole pipeline as [stage.NAME] tables, run in
order - so the whole pipeline lives in one file you can review and version-control:
[stage.hot]
verb = "where"
predicate = 'text has "ERROR"'
[stage.themes]
verb = "cluster"
explode = "members"
[stage.picture]
verb = "chart"
field = "cluster"
top = 8
save = "themes.svg"
cat week.log \
| smartpipe run triage.sem
smartpipe run triage.sem --dry-run # the graph + cost posture, zero calls
# → stage hot where text has "ERROR" [free]
# → stage themes cluster --explode members [model calls]
# → stage picture chart cluster --top 8 --save themes.svg [free]
Each stage reads the previous stage's output (input = "name" picks any
EARLIER stage instead); the first stage reads stdin, the last writes stdout.
Stage receipts on stderr carry their stage name ([hot] where: 214 of 9,102
matched). Stage keys are validated exactly like single-stage files - a typo
is a loud error, never silently ignored. Single-stage files are unchanged;
extra CLI flags apply to them only.