Add an ai derivation to a Folio sheet
Author a derivations/<target>.yaml of kind: ai, declare the
contract field as x-derived: true, and verify with one
folio materialize call.
When this skill applies
- The user says "make Folio fill this column with an LLM" or
"classify / summarize / extract / translate every row".
- The answer is fundamentally fuzzy — free-text classification,
summarization, extraction from messy inputs.
- The user wants Folio to manage the cache, retries, prompt versioning,
and cost reporting — i.e. they don't just want a
for loop calling
the SDK.
This skill does not apply when:
- The answer is deterministic (use
kind: python or kind: sql).
- The data already exists in a CSV / JSON file (use
kind: import).
- The data lives in another Folio sheet keyed by the same PK (use
kind: cross_sheet — see the add-derivation-cross-sheet skill).
Prerequisites
- A working Folio sheet (see
folio-quickstart). You should already
have contract.yaml and records.jsonl and folio validate <sheet>
exits 0.
- An
ANTHROPIC_API_KEY (or whichever provider your AIClient targets)
in the environment, or a StubAIClient for offline runs.
folio --help shows the materialize subcommand.
Procedure
Pick the target field name — what column the LLM will fill.
Convention: snake_case ASCII, the same as other fields. Add it to
contract.yaml with x-derived: true so it's clearly not a
human-edited column:
- name: industry_tag
logicalType: string
x-derived: true
x-inputs: [company_name] # mirrors `inputs:` in the derivation
List inputs — every field the prompt reads. The cache hashes
these, so an honest list is what makes the cache correct. If the
prompt reads no field (very rare for ai), use inputs: [].
Choose output: text or output: json. One target → text.
Multi-target → json plus output_schema. There is no "list" or
"tuple" output — you express that as a JSON object.
Write the prompt. Use prompt: for one-liners; prompt_ref: for
anything multi-paragraph. prompt_ref paths are relative to the
sheet root and the file's bytes go into input_hash, so editing
the prompt invalidates the cache (correct behaviour).
Create derivations/<target>.yaml. Skeleton (single target,
text output):
# derivations/industry_tag.yaml
targets: [industry_tag]
inputs: [company_name]
kind: ai
model: claude-sonnet-4-6
prompt: |
Industry of {{ company_name }} in one word.
output: text
Multi-target with structured JSON output:
# derivations/enrich.yaml
targets: [industry, employee_count]
inputs: [company_name, country]
kind: ai
model: claude-sonnet-4-6
prompt_ref: prompts/enrich.md
output: json
output_schema:
industry: string
employee_count: integer
(Optional) Tune the loop. materialization: is a sub-block:
materialization:
respect_human_override: true # default — skip cells a human edited
retries: 0 # default
retry_delay_seconds: 1.0
Retries cover AIClient errors only (timeouts, rate-limits). A
missing output_schema key is a deterministic content error — it
does not retry.
Validate, then run materialize on one record. Always smoke-test
on a single record before letting the LLM loose on every row:
folio validate ./customers
folio materialize ./customers industry_tag \
--actor agent:demo \
--ids cust_001
folio materialize takes the target as a positional argument
(one at a time; omit it to materialize every derivation), and
--ids is comma-separated or repeated.
The output is the §10.6 envelope:
{"materialized": 1, "skipped": 0, "failures": [], "total_cost": 0.0021}
Inspect the value & provenance. Read it back:
folio list ./customers --filter "id = ?" --param cust_001
folio provenance ./customers cust_001 industry_tag
folio provenance takes the record ID and field as positional
arguments. The provenance line includes model, input_hash,
and cost_usd.
Verify
folio validate <sheet>
folio materialize <sheet> <field> --actor agent:demo --ids <one_id>
Both should exit 0 and the envelope's failures should be [].
Tips & idioms
- Substitution shape.
{{ field }} substitutes the JSON-encoded
value — strings get quotes, integers stay bare, arrays become bracket
lists. This keeps prompts safe against quotes / newlines in the data.
- Deterministic first, AI fallback. Common pattern: a
python
derivation that handles the easy cases, then an ai derivation on
the long tail. Keep the AI rows scarce — the cache is the savings.
- Prompts in their own file. Once a prompt is more than ~3 lines,
use
prompt_ref: and put the file under prompts/. Reviewers hate
diffs of multi-line YAML strings.
- Multi-target = one cache key. All targets in a multi-target
derivation share an
input_hash. They update together or stay
cached together — that's the invariant.
Common mistakes (don't make them)
- Forgetting
x-derived: true in contract.yaml. The materialize
loop still runs, but folio status and human editors will treat
the field as a normal user-editable column.
output: text with multiple targets. Folio will reject the
contract — text output writes one cell. Use output: json +
output_schema.
- Listing inputs the prompt doesn't actually read. The cache
re-runs every time those (irrelevant) fields change. Keep
inputs:
honest.
- Editing the prompt without expecting a re-run. The prompt body is
in the
input_hash. That's the design — it means an old, outdated
answer cannot stick around silently.
- Hardcoding an API key in
derivations/*.yaml. Folio reads keys
from the environment. The YAML stays clean and shippable.
1---2name: add-derivation-ai3description: Wire up a Folio `kind: ai` derivation — the YAML file under `derivations/`, the prompt template (or `prompt_ref`), `output: text` vs `output: json` with `output_schema`, and a one-row materialize smoke. Invoke when the user asks to "have an LLM fill a column", "auto-classify", "summarize each row", or anything that maps a free-text field to a structured value via Claude/OpenAI/etc.4---56# Add an `ai` derivation to a Folio sheet78Author a `derivations/<target>.yaml` of `kind: ai`, declare the9contract field as `x-derived: true`, and verify with one10`folio materialize` call.1112## When this skill applies1314- The user says "make Folio fill this column with an LLM" or15 "classify / summarize / extract / translate every row".16- The answer is *fundamentally fuzzy* — free-text classification,17 summarization, extraction from messy inputs.18- The user wants Folio to manage the cache, retries, prompt versioning,19 and cost reporting — i.e. they don't just want a `for` loop calling20 the SDK.2122This skill does **not** apply when:2324- The answer is deterministic (use `kind: python` or `kind: sql`).25- The data already exists in a CSV / JSON file (use `kind: import`).26- The data lives in another Folio sheet keyed by the same PK (use27 `kind: cross_sheet` — see the `add-derivation-cross-sheet` skill).2829## Prerequisites3031- A working Folio sheet (see `folio-quickstart`). You should already32 have `contract.yaml` and `records.jsonl` and `folio validate <sheet>`33 exits 0.34- An `ANTHROPIC_API_KEY` (or whichever provider your AIClient targets)35 in the environment, **or** a `StubAIClient` for offline runs.36- `folio --help` shows the `materialize` subcommand.3738## Procedure39401. **Pick the target field name** — what column the LLM will fill.41 Convention: snake_case ASCII, the same as other fields. Add it to42 `contract.yaml` with `x-derived: true` so it's clearly not a43 human-edited column:4445 ```yaml46 - name: industry_tag47 logicalType: string48 x-derived: true49 x-inputs: [company_name] # mirrors `inputs:` in the derivation50 ```51522. **List `inputs`** — every field the prompt reads. The cache hashes53 these, so an honest list is what makes the cache correct. If the54 prompt reads no field (very rare for `ai`), use `inputs: []`.55563. **Choose `output: text` or `output: json`.** One target → `text`.57 Multi-target → `json` plus `output_schema`. There is no "list" or58 "tuple" output — you express that as a JSON object.59604. **Write the prompt.** Use `prompt:` for one-liners; `prompt_ref:` for61 anything multi-paragraph. `prompt_ref` paths are relative to the62 sheet root and the file's bytes go into `input_hash`, so editing63 the prompt invalidates the cache (correct behaviour).64655. **Create `derivations/<target>.yaml`.** Skeleton (single target,66 text output):6768 ```yaml69 # derivations/industry_tag.yaml70 targets: [industry_tag]71 inputs: [company_name]72 kind: ai73 model: claude-sonnet-4-674 prompt: |75 Industry of {{ company_name }} in one word.76 output: text77 ```7879 Multi-target with structured JSON output:8081 ```yaml82 # derivations/enrich.yaml83 targets: [industry, employee_count]84 inputs: [company_name, country]85 kind: ai86 model: claude-sonnet-4-687 prompt_ref: prompts/enrich.md88 output: json89 output_schema:90 industry: string91 employee_count: integer92 ```93946. **(Optional) Tune the loop.** `materialization:` is a sub-block:9596 ```yaml97 materialization:98 respect_human_override: true # default — skip cells a human edited99 retries: 0 # default100 retry_delay_seconds: 1.0101 ```102103 Retries cover **AIClient errors only** (timeouts, rate-limits). A104 missing `output_schema` key is a deterministic content error — it105 does not retry.1061077. **Validate, then run materialize on one record.** Always smoke-test108 on a single record before letting the LLM loose on every row:109110 ```bash111 folio validate ./customers112 folio materialize ./customers industry_tag \113 --actor agent:demo \114 --ids cust_001115 ```116117 `folio materialize` takes the target as a positional argument118 (one at a time; omit it to materialize every derivation), and119 `--ids` is comma-separated or repeated.120121 The output is the §10.6 envelope:122123 ```json124 {"materialized": 1, "skipped": 0, "failures": [], "total_cost": 0.0021}125 ```1261278. **Inspect the value & provenance.** Read it back:128129 ```bash130 folio list ./customers --filter "id = ?" --param cust_001131 folio provenance ./customers cust_001 industry_tag132 ```133134 `folio provenance` takes the record ID and field as positional135 arguments. The provenance line includes `model`, `input_hash`,136 and `cost_usd`.137138## Verify139140```bash141folio validate <sheet>142folio materialize <sheet> <field> --actor agent:demo --ids <one_id>143```144145Both should exit 0 and the envelope's `failures` should be `[]`.146147## Tips & idioms148149- **Substitution shape.** `{{ field }}` substitutes the **JSON-encoded**150 value — strings get quotes, integers stay bare, arrays become bracket151 lists. This keeps prompts safe against quotes / newlines in the data.152- **Deterministic first, AI fallback.** Common pattern: a `python`153 derivation that handles the easy cases, then an `ai` derivation on154 the long tail. Keep the AI rows scarce — the cache is the savings.155- **Prompts in their own file.** Once a prompt is more than ~3 lines,156 use `prompt_ref:` and put the file under `prompts/`. Reviewers hate157 diffs of multi-line YAML strings.158- **Multi-target = one cache key.** All targets in a multi-target159 derivation share an `input_hash`. They update together or stay160 cached together — that's the invariant.161162## Common mistakes (don't make them)163164- **Forgetting `x-derived: true` in `contract.yaml`.** The materialize165 loop still runs, but `folio status` and human editors will treat166 the field as a normal user-editable column.167- **`output: text` with multiple `targets`.** Folio will reject the168 contract — text output writes one cell. Use `output: json` +169 `output_schema`.170- **Listing inputs the prompt doesn't actually read.** The cache171 re-runs every time those (irrelevant) fields change. Keep `inputs:`172 honest.173- **Editing the prompt without expecting a re-run.** The prompt body is174 in the `input_hash`. That's the design — it means an old, outdated175 answer cannot stick around silently.176- **Hardcoding an API key in `derivations/*.yaml`.** Folio reads keys177 from the environment. The YAML stays clean and shippable.