Tasks
Keel supports composing programs from multiple files using run "file" expressions. All file reading, parsing, and inlining happens at compile time — there are no runtime file operations.
Running a Task
Use run "path" to run another file as a task. The expression returns a record of exposed values, which you destructure with let:
let x = 5
let { result } = run "./compute.kl" { x }
result -- value computed by compute.kl
The compiler reads the target file, type-checks it, and inlines its code at that point. The run expression returns a record whose fields are the callee's exposed variables.
The Task Declaration
The target file declares its inputs and outputs with a task declaration:
-- ./compute.kl
task expecting { x : Int } exposing { result : Int }
let result = x * 2
expecting { x : Int }declares typed input parameters — what the caller must passexposing { result : Int }declares typed output variables — the fields of the returned record
Passing Variables
run supports three forms for passing variables:
| Form | Meaning | Example |
|---|---|---|
{ x, y } | Named variables | run "./f.kl" { x, y } |
{ .. } | All variables | run "./f.kl" { .. } |
| (absent) | No variables | run "./f.kl" |
The argument list is optional when no variables need to be passed — run "./f.kl" passes nothing.
Receiving Values
The run expression returns a record, so you use record destructuring to receive values:
-- Destructure specific fields
let { result } = run "./compute.kl" { x }
-- Destructure multiple fields
let { sum, product } = run "./math.kl" { a, b }
-- Ignore the result
let _ = run "./side_effect.kl"
Multiple Variables
Pass and receive multiple variables:
-- ./math.kl
task expecting { a : Int, b : Int } exposing { sum : Int, product : Int }
let sum = a + b
let product = a * b
let a = 3
let b = 7
let { sum, product } = run "./math.kl" { a, b }
sum -- 10
product -- 21
Variable Flow
Variables move between files in a controlled, explicit way:
- The caller passes variables:
run "file.kl" { x, y }orrun "file.kl" { .. } - The callee declares typed parameters:
task expecting { x : Int, y : Int } exposing ... - The callee declares typed outputs:
exposing { result : Int } - The caller destructures the returned record:
let { result } = run ...
Variables not passed are invisible to the callee. Variables not in the output declaration are not returned. This keeps file boundaries explicit.
Output-Only Tasks
A callee doesn't need input parameters. It can simply compute values and expose them:
-- constants.kl
task exposing { answer : Int }
let answer = 7 * 6
let { answer } = run "./constants.kl"
answer -- 42
Working with Strings
Tasks work with all Keel types:
-- greet.kl
task expecting { name : String } exposing { greeting : String }
let greeting = "Hello, " ++ name
let name = "World"
let { greeting } = run "./greet.kl" { name }
greeting -- "Hello, World"
Working with DataFrames
Tasks can declare typed DataFrame parameters with column schemas, giving you compile-time validation of the data flowing between files.
Typed DataFrame parameter — a task that expects specific columns:
-- analyze.kl
task expecting { data : DataFrame { name : String, score : Int } } exposing { average : Maybe Float }
let scores = (data |> DataFrame.column @score)?
let unwrapped = scores |> List.andThen (|m|
case m of
Just x -> [x]
Nothing -> [])
let average = unwrapped |> List.mean
let data = DataFrame.fromRecords [ { name = "Alice", score = 85 }, { name = "Bob", score = 92 } ]
let { average } = run "./analyze.kl" { data }
Open schema (..) — require certain columns but allow extras:
-- summarize.kl
task expecting { data : DataFrame { score : Int, .. } } exposing { total : Int }
let total = (data |> DataFrame.column @score)? |> List.sum
This accepts any DataFrame that has a score : Int column, regardless of other columns.
Newtype for readability:
type StudentData = DataFrame { name : String, score : Int }
task expecting { data : StudentData } exposing { average : Float }
Symbol column names — when column names don't match Keel's lowercase identifier syntax (uppercase, spaces, etc.), use symbol syntax:
-- Uppercase column names
task expecting { data : DataFrame { :Name : String, :Score : Int } } exposing { avg : Float }
-- Quoted column names for spaces
task expecting { data : DataFrame { :"First Name" : String, :Age : Int } } exposing { count : Int }
-- Mix of lowercase identifiers and symbols
task expecting { data : DataFrame { id : Int, :Name : String } } exposing { result : String }
Enum column contracts — enum types (including ValueLabel enums) work as column types in both expecting and exposing declarations. The annotation is the source of truth: a callee can declare exposing { result : DataFrame { gender: Gender } } even when the underlying file stores the column as integers. See Data Contracts for details.
Sequential Tasks
You can run multiple tasks in sequence. Each task can use values from earlier tasks:
let x = 5
let { a } = run "./step1.kl" { x }
let { b } = run "./step2.kl" { x }
a + b
Chained Tasks
Task files can themselves run other tasks, creating chains of composition:
-- ./inner.kl
task expecting { n : Int } exposing { doubled : Int }
let doubled = n * 2
-- ./outer.kl
task expecting { x : Int } exposing { final_result : Int }
let n = x
let { doubled } = run "./inner.kl" { n }
let final_result = doubled + x
let x = 5
let { final_result } = run "./outer.kl" { x }
final_result -- 15
Path Resolution
run paths follow a two-mode convention based on the prefix:
| Path form | Anchors to | Example |
|---|---|---|
"./file.kl" or "../dir/file.kl" | Calling file's directory | run "./helper.kl" |
"steps/extract.kl" (no ./) | Project root (keel.toml directory) | run "steps/extract.kl" |
Caller-relative paths start with ./ or ../. They resolve from the directory of the file that contains the run expression — the same rule that ./ follows on the shell.
Project-root paths are bare (no leading ./). They resolve from the directory containing keel.toml. This makes them stable regardless of where in the project the calling file lives.
-- src/pipeline/main.kl
-- Both of these resolve unambiguously:
let { a } = run "./normalize.kl" { raw } -- src/pipeline/normalize.kl
let { b } = run "steps/load.kl" { path } -- <project-root>/steps/load.kl
When no keel.toml is found, bare paths produce a compile error asking you to add a ./ prefix or create a keel.toml. Caller-relative paths always work regardless of project structure.
For deeply nested task chains, use ./ throughout so each file is self-contained and works correctly both when run from the project entry point and when opened directly in an editor:
-- pipeline/step1.kl
task expecting { raw : DataFrame { .. } } exposing { cleaned : DataFrame { .. } }
let { cleaned } = run "./clean.kl" { raw } -- ./pipeline/clean.kl
-- pipeline/clean.kl
task expecting { raw : DataFrame { .. } } exposing { cleaned : DataFrame { .. } }
let { cleaned } = run "./validate.kl" { raw } -- ./pipeline/validate.kl
Each file can be edited and run in isolation, and the paths are correct regardless of which file is the entry point.
Imports in Task Files
Task files can import modules just like any other file. Imports go before the task declaration, and everything they bring into scope — functions, types, and enums — is available in the task body:
-- colors.kl (a module)
module exposing ..
enum Color = Red | Green | Blue
fn toHex : Color -> String
fn toHex c = case c of
Color::Red -> "#ff0000"
Color::Green -> "#00ff00"
Color::Blue -> "#0000ff"
-- render.kl (a task)
import Colors exposing Color, toHex
task expecting { name : String } exposing { result : String }
let color = Color::Green
let result = name ++ ": " ++ toHex color
let name = "status"
let { result } = run "./render.kl" { name }
result -- "status: #00ff00"
Error Handling
The compiler catches task errors at compile time:
| Error | Cause |
|---|---|
| File not found | The file path doesn't resolve to an existing file |
| Parse errors in task file | The referenced file has syntax errors |
| Circular task dependency | File A runs B, which runs A (or a file runs itself) |
| Missing expected variable | The callee expects a parameter the caller didn't pass |
| Extra variable | The caller passes a variable the callee doesn't expect |
| Type mismatch | A passed variable's type doesn't match the expected type |
| No project root | A bare path (no ./) is used but no keel.toml was found — add ./ or add a keel.toml |
All errors are reported at compile time — there are no runtime surprises.
Best Practices
- Keep task files focused — each file should do one thing
- Be explicit about interfaces — use typed
taskdeclarations to document inputs and outputs - Prefer named variables —
run "f.kl" { x }is clearer thanrun "f.kl" { .. } - Destructure only what you need —
let { result } = run ...is clearer than binding everything - Avoid deep nesting — chained tasks are powerful, but deep chains become hard to follow
- Use descriptive file names —
compute_totals.klis clearer thanstep2.kl
Next Steps
Learn about error handling to understand Keel's helpful error messages, or explore the standard library for built-in functions.