Skip to content

Tutorial

This walkthrough covers every major feature of lackpy. Follow along in order, or jump to any section.

Prerequisites: lackpy installed, workspace initialized with lackpy init. See Getting Started.


1. Setup

Create a project directory and initialize:

mkdir my-lackpy-project && cd my-lackpy-project
echo "# Hello from lackpy" > README.md
lackpy init

Confirm the setup:

lackpy status
{
  "workspace": "/path/to/my-lackpy-project",
  "config_dir": "/path/to/my-lackpy-project/.lackpy",
  "inference_order": ["templates", "rules"],
  "kit_default": "debug",
  "sandbox_enabled": false,
  "tools": 4
}

2. Pipeline overview

Every delegate call passes through this pipeline:

                    ┌─────────────────────────────────────────┐
  intent            │  InferenceDispatcher                    │
  ─────────────────►│                                         │
                    │  tier 0: templates  (pattern match)     │
                    │  tier 1: rules      (keyword rules)     │
                    │  tier 2: ollama     (local LLM)         │
                    │  tier 3: anthropic  (cloud LLM)         │
                    └──────────────┬──────────────────────────┘
                                   │ raw program text
                    ┌──────────────────────────┐
                    │  Validator (AST check)   │
                    │  - allowed node types    │
                    │  - forbidden names       │
                    │  - namespace check       │
                    │  - custom rules          │
                    └──────────────┬───────────┘
                                   │ ValidationResult
                    ┌──────────────────────────┐
                    │  RestrictedRunner        │
                    │  - traced namespace      │
                    │  - restricted __builtins__│
                    └──────────────┬───────────┘
                                   │ ExecutionResult + Trace
                              delegate() result

The tier system means simple intents are handled deterministically and fast; LLMs are only invoked when no cheaper tier can handle the request.


3. Validating programs

The validator checks programs before they run. It operates entirely on the AST — no code is executed during validation.

A valid program

cat > check.py << 'EOF'
files = find_files("**/*.py")
count = len(files)
count
EOF

lackpy validate check.py --kit find_files
{
  "valid": true,
  "errors": [],
  "calls": ["find_files", "len"]
}

An invalid program

The following example shows what the validator rejects. This program uses import, which is a Forbidden AST node:

cat > bad.py << 'EOF'
import sys
sys.version
EOF

lackpy validate bad.py --kit find_files
{
  "valid": false,
  "errors": [
    "Forbidden AST node: Import at line 1",
    "Unknown function: 'sys' at line 2 (not in kit or builtins)"
  ],
  "calls": []
}

What gets rejected and why

Construct Reason
import / from ... import Arbitrary module access
def / class Code definition introduces opaque scope
while Unbounded loops
try / except Error suppression can hide security violations
lambda Anonymous functions bypass namespace checks
open, getattr, setattr Direct resource or reflection access
Strings containing __ Prevents getattr(obj, "__class__") patterns
Unknown function names Every call must be in the kit or allowed builtins

Python API

from lackpy import LackpyService

svc = LackpyService()
result = svc.validate(
    'files = find_files("**/*.py")\nlen(files)',
    kit=["find_files"],
)
print(result.valid)     # True
print(result.errors)    # []
print(result.calls)     # ['find_files', 'len']

4. Working with kits

A kit is a named subset of tools from the toolbox. Kits control which function names are visible to the validator and runner.

List available tools

lackpy toolbox list

Use a comma-separated kit

lackpy delegate "read the file README.md" --kit read_file,find_files

Create a named kit

lackpy kit create filesystem --tools read glob write --description "File system tools"

This creates .lackpy/kits/filesystem.kit:

---
name: filesystem
description: File system tools
---
read
glob
write

Use the named kit

lackpy delegate "find all Python files" --kit filesystem

Kit info

lackpy kit info filesystem
{
  "tools": {
    "read_file": {"description": "", "grade_w": 3, "effects_ceiling": 3},
    "find_files": {"description": "", "grade_w": 3, "effects_ceiling": 3},
    "write_file": {"description": "", "grade_w": 3, "effects_ceiling": 3}
  },
  "grade": {"w": 3, "d": 3},
  "description": "  read_file(path) -> Any: \n  find_files(pattern) -> Any: \n  write_file(path, content) -> Any: "
}

Python API — kit forms

svc = LackpyService()

# Named kit (from .lackpy/kits/filesystem.kit)
result = await svc.delegate("find all Python files", kit="filesystem")

# List of tool names
result = await svc.delegate("find all Python files", kit=["find_files"])

# Dict with aliases
result = await svc.delegate(
    "find all Python files",
    kit={"ls": "find_files"},  # calls are `ls(...)` in the program
)

5. Generating programs

generate runs the inference pipeline without executing the result:

lackpy generate "find all Python files" --kit find_files
files = find_files('**/*.py')
files

Inference tiers

Templates are matched first. If a template pattern matches the intent, the stored program is returned — no LLM required.

lackpy generate "read the file config.toml" --kit read_file
# → matched by rules tier: content = read_file('config.toml')

The rules tier uses keyword-based matching for common patterns. It handles intents like "read file X", "find definitions of Y", "glob Z".

If templates and rules fail, Ollama is tried. Requires pip install lackpy[ollama] and a running Ollama server.

# .lackpy/config.toml
[inference.providers.ollama-local]
plugin = "ollama"
model = "qwen2.5-coder:1.5b"

Cloud fallback. Requires pip install lackpy[full] and ANTHROPIC_API_KEY.

[inference.providers.anthropic-fallback]
plugin = "anthropic"
model = "claude-haiku-4-5-20251001"

Python API

result = await svc.generate("find all Python files", kit=["find_files"])
print(result.program)          # the generated program
print(result.provider_name)    # which tier produced it
print(result.generation_time_ms)

6. Running programs directly

Use run when you already have a program file:

cat > list_py.py << 'EOF'
files = find_files("**/*.py")
files
EOF

lackpy run list_py.py --kit find_files
{
  "success": true,
  "output": ["src/lackpy/__init__.py", "src/lackpy/cli.py"],
  "error": null
}

The program is validated before execution. If validation fails, the runner returns an error without running anything.

Python API

result = await svc.run_program(
    'files = find_files("**/*.py")\nfiles',
    kit=["find_files"],
)
print(result.success)
print(result.output)
print(result.trace.entries)   # list of TraceEntry

7. Using parameters

Parameters let you pass values into programs without interpolating them into the intent string:

result = await svc.delegate(
    intent="read the target file",
    kit=["read_file"],
    params={
        "target_file": {
            "value": "README.md",
            "type": "str",
            "description": "file to read",
        }
    },
)

Inside the program, target_file is available as a pre-set variable:

# generated program
content = read_file(target_file)
content

Name collisions

Parameter names must not collide with tool names or allowed builtins. LackpyService raises ValueError if they do.


8. Creating templates (the ratchet)

Once you have a working program, save it as a template to make future matching deterministic:

cat > read_file.py << 'EOF'
content = read_file('{path}')
content
EOF

lackpy create read_file.py \
  --name read-file \
  --pattern "read the file {path}" \
  --kit read_file

This creates .lackpy/templates/read-file.tmpl:

---
name: read-file
pattern: "read the file {path}"
success_count: 0
fail_count: 0
---
content = read_file('{path}')
content

Now lackpy delegate "read the file README.md" matches at tier 0, with {path} substituted to README.md. No LLM is called.

Python API

result = await svc.create(
    program="content = read_file('{path}')\ncontent",
    kit=["read_file"],
    name="read-file",
    pattern="read the file {path}",
)
print(result["path"])  # .lackpy/templates/read-file.tmpl

9. Custom rules

Custom rules let you tighten the validator beyond the built-in checks. They're callables: ast.Module -> list[str].

from lackpy.lang.rules import no_loops, max_calls, max_depth, no_nested_calls

# Use in validate
result = svc.validate(
    program,
    kit=["find_files"],
    rules=[no_loops, max_calls(5)],
)

# Use in delegate (enforced on the generated program)
result = await svc.delegate(
    "find all Python files",
    kit=["find_files"],
    rules=[no_loops, max_depth(2)],
)

Built-in rules

Rule Description
no_loops Rejects any for loop
max_depth(n) Limits nesting depth (if/for/with)
max_calls(n) Limits total number of function calls
no_nested_calls Forbids using a call result directly as an argument

See Custom Rules for writing your own.


10. Reading the trace

Every program run produces a Trace with an entry for each tool call:

result = await svc.run_program(
    'files = find_files("**/*.py")\ncount = len(files)\ncount',
    kit=["find_files"],
)

for entry in result.trace.entries:
    print(
        f"Step {entry.step}: {entry.tool}({entry.args}) "
        f"-> {entry.result} ({entry.duration_ms:.1f}ms)"
    )
Step 0: find_files({'pattern': '**/*.py'}) -> ['src/lackpy/__init__.py', ...] (1.3ms)

len is a builtin, not a tool, so it doesn't appear in the trace. Only kit tools are traced.

Trace fields

Field Type Description
step int Call order (0-indexed)
tool str Tool name
args dict Arguments by name
result Any Return value (truncated if large)
duration_ms float Wall-clock call time
success bool Whether the call succeeded
error str \| None Exception message if failed

The Trace also has files_read and files_modified lists, populated when tools are configured with effects metadata.


Next steps