Esc
Start typing to search...

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 pass
  • exposing { result : Int } declares typed output variables — the fields of the returned record

Passing Variables

run supports three forms for passing variables:

FormMeaningExample
{ x, y }Named variablesrun "./f.kl" { x, y }
{ .. }All variablesrun "./f.kl" { .. }
(absent)No variablesrun "./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:

  1. The caller passes variables: run "file.kl" { x, y } or run "file.kl" { .. }
  2. The callee declares typed parameters: task expecting { x : Int, y : Int } exposing ...
  3. The callee declares typed outputs: exposing { result : Int }
  4. 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 formAnchors toExample
"./file.kl" or "../dir/file.kl"Calling file's directoryrun "./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:

ErrorCause
File not foundThe file path doesn't resolve to an existing file
Parse errors in task fileThe referenced file has syntax errors
Circular task dependencyFile A runs B, which runs A (or a file runs itself)
Missing expected variableThe callee expects a parameter the caller didn't pass
Extra variableThe caller passes a variable the callee doesn't expect
Type mismatchA passed variable's type doesn't match the expected type
No project rootA 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

  1. Keep task files focused — each file should do one thing
  2. Be explicit about interfaces — use typed task declarations to document inputs and outputs
  3. Prefer named variablesrun "f.kl" { x } is clearer than run "f.kl" { .. }
  4. Destructure only what you needlet { result } = run ... is clearer than binding everything
  5. Avoid deep nesting — chained tasks are powerful, but deep chains become hard to follow
  6. Use descriptive file namescompute_totals.kl is clearer than step2.kl

Next Steps

Learn about error handling to understand Keel's helpful error messages, or explore the standard library for built-in functions.