Need help? Join our Discord — ask questions, share projects, and connect with the Quill community.

Standard Library

Quill comes with 130+ built-in functions that are available everywhere — no imports needed. This page documents every function with plain English explanations and practical examples.

New to programming? Think of the standard library as a toolbox. Just like a carpenter has a hammer, saw, and measuring tape, Quill gives you ready-made tools for working with text, lists, numbers, files, and more. You don't need to build these tools yourself, they're already here for you.

Quick Reference

Jump to any category:

Category Key Functions
Lists / Arrays length, push, pop, sort, sortBy, filter, map_list, find, includes
Strings / Text trim, upper, lower, split, join, replace_text, startsWith
Numbers & Math round, floor, ceil, abs, random, randomInt, toNumber
Objects keys, values, entries, merge, pick, omit
Type Checking isNumber, isText, isList, isObject, isNothing
File I/O readFile, writeFile, exists, listFiles, deleteFile
HTTP / Fetch fetch, fetchJSON, serve, putJSON, patchJSON, deleteJSON
Number Formatting formatNumber, toFixed, percent, currency, ordinal, clamp, lerp, mapRange
Drawing & Charts drawCircle, drawRect, drawLine, drawText, chart, showCanvas
Patterns & RegExp matches, matchesPattern, replacePattern, regex, matchGroups, matchFirst
Environment env, args, run, exit, platform
Crypto & Encoding hash, encrypt, decrypt, generateKeys, hmac, randomBytes, uuid
Date & Time now, formatDate, parseDate, timeAgo
Buffer & Encoding toBuffer, fromBuffer, toBase64, fromBase64, toHex, fromHex
CLI Tools arg, args, flag, hasFlag, colors, exitWith
Cron Jobs every N seconds, every N minutes, every N hours
HTTPS Server createSecureServer
Secure Storage SecureStorage.set, SecureStorage.get, SecureStorage.remove, SecureStorage.clear
WebRTC createPeer, PeerConnection
Signal Protocol Primitives hkdf, hkdfBuffer, diffieHellman, argon2, bcryptHash, constantTimeEqual, aesEncrypt, hmacBuffer, randomBytesBuffer, secureErase, defineSchema
AI & LLM ask claude, ask openai, ask gemini, ask ollama, stream, embed, createVectorStore, extract, chunk, agentsee AI docs

Working with Lists

Lists are one of the most useful things in programming. A list is just a collection of items — like a shopping list, a list of names, or a list of scores. These functions help you create, search, filter, transform, and analyze lists.

length(x) — Count items

Returns the number of items in a list (or characters in text). This is one of the most common functions you'll use.

-- How many items in my list?
fruits are ["apple", "banana", "cherry"]
say length(fruits)   -- 3

-- Also works on text
say length("hello")   -- 5

-- Practical: check if a list is empty
if length(results) is 0:
  say "No results found"

push(list, item) — Add an item

Adds an item to the end of a list. Like adding something to the bottom of your to-do list.

tasks are ["buy milk", "walk dog"]
push(tasks, "call mom")
say tasks   -- ["buy milk", "walk dog", "call mom"]

pop(list) — Remove the last item

Removes and returns the last item from a list.

stack are [1, 2, 3]
last is pop(stack)
say last    -- 3
say stack   -- [1, 2]

sort(list) — Sort items

Returns a new list with items sorted in order (smallest to largest for numbers, A-Z for text). The original list is not changed.

scores are [85, 92, 71, 100, 63]
say sort(scores)   -- [63, 71, 85, 92, 100]

names are ["Charlie", "Alice", "Bob"]
say sort(names)   -- ["Alice", "Bob", "Charlie"]

reverse(list) — Reverse order

Returns a new list with items in the opposite order.

countdown are [1, 2, 3, 4, 5]
say reverse(countdown)   -- [5, 4, 3, 2, 1]

unique(list) — Remove duplicates

Returns a new list with duplicate items removed. Like removing repeat entries from a guest list.

tags are ["js", "python", "js", "go", "python"]
say unique(tags)   -- ["js", "python", "go"]

includes(list, item) — Check if item exists

Returns yes if the list contains the item, no otherwise. Like checking if a name is on a guest list.

colors are ["red", "green", "blue"]
say includes(colors, "green")    -- true
say includes(colors, "purple")   -- false

indexOf(list, item) — Find position

Returns the position (index) of an item in a list. Returns -1 if not found. Remember: positions start at 0, not 1.

letters are ["a", "b", "c", "d"]
say indexOf(letters, "c")   -- 2 (third item, but index starts at 0)
say indexOf(letters, "z")   -- -1 (not found)

slice(list, start, end) — Get a portion

Returns a portion of a list from start to end (not including end). Like cutting a section out of a list.

numbers are [10, 20, 30, 40, 50]
say slice(numbers, 1, 4)   -- [20, 30, 40]
say slice(numbers, 2)       -- [30, 40, 50] (from index 2 to the end)

concat(a, b) — Combine two lists

Joins two lists together into one new list.

first are [1, 2]
second are [3, 4]
say concat(first, second)   -- [1, 2, 3, 4]

flat(list) — Flatten nested lists

Takes a list of lists and flattens it into a single list.

nested are [[1, 2], [3, 4], [5]]
say flat(nested)   -- [1, 2, 3, 4, 5]

range(start, end) — Generate number sequences

Creates a list of numbers from start up to (but not including) end. Very useful for generating sequences.

say range(1, 6)    -- [1, 2, 3, 4, 5]
say range(0, 10)   -- [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

zip(a, b) — Pair up items

Takes two lists and pairs them up into a list of pairs. Like matching students with their grades.

names are ["Alice", "Bob", "Charlie"]
scores are [95, 82, 91]
say zip(names, scores)
-- [["Alice", 95], ["Bob", 82], ["Charlie", 91]]

sum(list) — Add up numbers

Returns the total of all numbers in a list.

prices are [9.99, 14.50, 3.25]
say sum(prices)   -- 27.74

smallest(list) / largest(list) — Find extremes

Find the smallest or largest value in a list of numbers.

temps are [72, 68, 75, 61, 80]
say smallest(temps)   -- 61
say largest(temps)    -- 80

filter(list, function) — Keep matching items

Creates a new list containing only the items that pass a test. Think of it like a sieve — only items that match your criteria get through.

-- Keep only even numbers
numbers are [1, 2, 3, 4, 5, 6, 7, 8]
evens is filter(numbers, with n: n % 2 is 0)
say evens   -- [2, 4, 6, 8]

-- Keep only names that start with "A"
names are ["Alice", "Bob", "Anna", "Charlie"]
aNames is filter(names, with n: startsWith(n, "A"))
say aNames   -- ["Alice", "Anna"]

map_list(list, function) — Transform every item

Creates a new list by applying a function to every item. Like running each item through a machine that changes it.

-- Double every number
numbers are [1, 2, 3, 4, 5]
doubled is map_list(numbers, with n: n * 2)
say doubled   -- [2, 4, 6, 8, 10]

-- Make all names uppercase
names are ["alice", "bob"]
say map_list(names, with n: upper(n))   -- ["ALICE", "BOB"]

find(list, function) — Find first match

Returns the first item that passes a test. Returns nothing if no match is found.

users are [
  {name: "Alice", age: 30},
  {name: "Bob", age: 17},
  {name: "Charlie", age: 25}
]
adult is find(users, with u: u.age is greater than 18)
say adult.name   -- "Alice"

every(list, function) — Do ALL items match?

Returns yes if every single item passes the test.

ages are [25, 30, 18, 42]
allAdults is every(ages, with a: a is greater than 17)
say allAdults   -- true

some(list, function) — Does ANY item match?

Returns yes if at least one item passes the test.

scores are [45, 62, 38, 91]
anyPassing is some(scores, with s: s is greater than 60)
say anyPassing   -- true

reduce(list, function, initial) — Combine into one value

Takes a list and combines all items into a single value by applying a function repeatedly. This is like folding a list down into one result.

-- Sum all numbers (the long way)
numbers are [1, 2, 3, 4, 5]
total is reduce(numbers, with a, b: a + b)
say total   -- 15

-- Find the longest word
words are ["cat", "elephant", "dog"]
longest is reduce(words, with a, b: if length(a) is greater than length(b) then a otherwise b)

countWhere(list, function) — Count matches

Counts how many items pass a test.

ages are [15, 22, 17, 30, 12]
adults is countWhere(ages, with a: a is greater than 17)
say adults   -- 2

groupBy(list, function) — Group items

Splits a list into groups based on a function. Returns an object where keys are group names and values are lists.

people are [
  {name: "Alice", dept: "engineering"},
  {name: "Bob", dept: "design"},
  {name: "Charlie", dept: "engineering"}
]
teams is groupBy(people, with p: p.dept)
-- { engineering: [{...}, {...}], design: [{...}] }

sortBy(list, field) — Sort by a field

Sorts a list of objects by a given field name.

users are [{name: "Charlie", age: 30}, {name: "Alice", age: 25}, {name: "Bob", age: 35}]
sorted is sortBy(users, "name")
-- [{name: "Alice", age: 25}, {name: "Charlie", age: 30}, {name: "Bob", age: 35}]

sorted is sortBy(users, "age")
-- [{name: "Alice", age: 25}, {name: "Charlie", age: 30}, {name: "Bob", age: 35}]

Working with Text

Text (also called "strings" in other languages) is everywhere in programming — names, messages, file contents, web pages. These functions help you search, transform, and manipulate text.

trim(text) — Remove extra spaces

Removes spaces (and tabs, newlines) from the beginning and end of text. Very useful when processing user input.

input is "  hello world  "
say trim(input)   -- "hello world"

upper(text) / lower(text) — Change case

Converts text to ALL UPPERCASE or all lowercase.

say upper("hello")   -- "HELLO"
say lower("HELLO")   -- "hello"

-- Useful for case-insensitive comparisons
if lower(answer) is "yes":
  say "Confirmed!"

capitalize(text) — Capitalize first letter

Makes the first letter uppercase. Good for formatting names and titles.

say capitalize("hello")   -- "Hello"
say capitalize("alice")   -- "Alice"

replace_text(text, old, new) — Find and replace

Replaces all occurrences of old with new in the text.

msg is "Hello, World! Hello, Everyone!"
say replace_text(msg, "Hello", "Hi")
-- "Hi, World! Hi, Everyone!"

startsWith(text, prefix) / endsWith(text, suffix)

Check if text begins or ends with a specific string. Returns yes or no.

filename is "photo.jpg"
say endsWith(filename, ".jpg")   -- true
say startsWith(filename, "doc")  -- false

split(text, separator) — Break text apart

Splits text into a list using the separator. Like cutting a sentence into individual words.

-- Split by comma
csv is "apple,banana,cherry"
fruits is split(csv, ",")
say fruits   -- ["apple", "banana", "cherry"]

-- Split by space to get words
sentence is "the quick brown fox"
say split(sentence, " ")   -- ["the", "quick", "brown", "fox"]

join(list, separator) — Combine into text

The opposite of split — joins a list of items into a single text string. Defaults to ", " if no separator given.

colors are ["red", "green", "blue"]
say join(colors, " and ")   -- "red and green and blue"
say join(colors)             -- "red, green, blue"

words(text) — Split into words

Splits text into a list of words (by whitespace). Handles multiple spaces gracefully.

say words("hello  beautiful  world")
-- ["hello", "beautiful", "world"]

lines(text) — Split into lines

Splits text into a list of lines.

poem is "roses are red\nviolets are blue"
say lines(poem)   -- ["roses are red", "violets are blue"]

padStart(text, length, char) / padEnd(text, length, char)

Pads text to a certain length by adding characters to the start or end. Great for formatting numbers and aligning output.

-- Pad a number with leading zeros
say padStart("42", 6, "0")   -- "000042"

-- Pad the end with dots
say padEnd("Loading", 10, ".")   -- "Loading..."

repeat(text, count) — Repeat text

Repeats text a given number of times.

say repeat("ha", 3)     -- "hahaha"
say repeat("=-", 20)   -- a nice separator line

truncate(text, length) — Shorten text

Cuts text to a maximum length and adds ... if it was shortened. Perfect for previews.

title is "This is a very long article title that needs shortening"
say truncate(title, 25)   -- "This is a very long artic..."

Math & Numbers

Functions for rounding, random numbers, and converting between types.

round(n) / floor(n) / ceil(n) — Rounding

round rounds to the nearest whole number. floor always rounds down. ceil always rounds up.

say round(3.7)   -- 4
say round(3.2)   -- 3
say floor(3.9)   -- 3 (always rounds down)
say ceil(3.1)    -- 4 (always rounds up)

abs(n) — Absolute value

Returns the positive version of a number. Removes the negative sign if present.

say abs(-5)    -- 5
say abs(5)     -- 5

random() — Random decimal

Returns a random number between 0 and 1.

say random()   -- 0.7234... (different each time)

randomInt(min, max) — Random whole number

Returns a random whole number between min and max (inclusive). Like rolling dice.

-- Roll a 6-sided die
roll is randomInt(1, 6)
say "You rolled: " + toText(roll)

-- Pick a random card number
card is randomInt(1, 13)

toNumber(x) / toText(x) — Convert types

Convert between numbers and text. You'll use toText often when combining numbers with text in output.

say toNumber("42")       -- 42 (now a number)
say toText(42)           -- "42" (now text)
say "Score: " + toText(100)   -- "Score: 100"

typeOf(x) — Check what type something is

Returns the type of a value as text: "text", "number", "boolean", "list", "object", "nothing", or "function".

say typeOf("hello")   -- "string"
say typeOf(42)        -- "number"
say typeOf([1,2])    -- "list"
say typeOf(nothing)   -- "nothing"

Number Formatting

Format numbers for display — add commas, decimal places, percentages, currency symbols, and more. Also includes utility functions for clamping and interpolation.

formatNumber(n) — Format with locale separators

Formats a number with commas (or your locale's separator) for readability.

say formatNumber(1234567)   -- "1,234,567"

toFixed(n, digits) — Fixed decimal places

Returns a text representation of a number with exactly the specified number of decimal places.

say toFixed(3.14159, 2)   -- "3.14"

percent(n) — Convert to percentage

Converts a decimal number to a percentage string.

say percent(0.75)   -- "75%"

currency(n, symbol) — Format as currency

Formats a number as currency with an optional symbol (defaults to "$").

say currency(9.99, "$")     -- "$9.99"
say currency(1234.5)         -- "$1,234.50"

ordinal(n) — Add ordinal suffix

Returns a number with its ordinal suffix (st, nd, rd, th).

say ordinal(1)    -- "1st"
say ordinal(2)    -- "2nd"
say ordinal(3)    -- "3rd"
say ordinal(11)   -- "11th"

clamp(n, min, max) — Constrain to range

Constrains a number to be within a given range. If the number is below min, returns min. If above max, returns max.

say clamp(150, 0, 100)   -- 100
say clamp(-5, 0, 100)    -- 0

lerp(start, end, t) — Linear interpolation

Linearly interpolates between start and end by the factor t (where 0 = start, 1 = end).

say lerp(0, 100, 0.5)    -- 50
say lerp(0, 100, 0.25)   -- 25

mapRange(value, inMin, inMax, outMin, outMax) — Map from one range to another

Maps a value from one range to another. Useful for converting between different scales.

say mapRange(50, 0, 100, 0, 1)   -- 0.5

Objects & Data

Objects are like labeled containers — they store values with names (keys). Think of an object as a form with labeled fields: name, age, email. These functions help you inspect, combine, and transform objects.

keys(obj) / values(obj) — Get keys or values

keys returns a list of all the field names. values returns a list of all the values.

user is {name: "Alice", age: 30, city: "NYC"}
say keys(user)     -- ["name", "age", "city"]
say values(user)   -- ["Alice", 30, "NYC"]

entries(obj) — Get key-value pairs

Returns a list of [key, value] pairs. Useful for looping through an object.

config is {theme: "dark", lang: "en"}
for each pair in entries(config):
  say pair[0] + " = " + pair[1]
-- "theme = dark"
-- "lang = en"

fromEntries(pairs) — Build object from pairs

The opposite of entries — creates an object from a list of [key, value] pairs.

pairs are [["name", "Bob"], ["age", 25]]
say fromEntries(pairs)   -- {name: "Bob", age: 25}

merge(obj1, obj2, ...) — Combine objects

Merges multiple objects into one. If the same key appears in multiple objects, the last one wins.

defaults is {theme: "light", lang: "en", fontSize: 14}
userPrefs is {theme: "dark", fontSize: 18}
settings is merge(defaults, userPrefs)
say settings
-- {theme: "dark", lang: "en", fontSize: 18}

pick(obj, keys...) — Select specific fields

Creates a new object with only the fields you want. Like picking columns from a spreadsheet.

user is {name: "Alice", age: 30, email: "alice@example.com", password: "secret"}
safe is pick(user, "name", "email")
say safe   -- {name: "Alice", email: "alice@example.com"}

omit(obj, keys...) — Remove specific fields

The opposite of pick — creates a new object without the specified fields.

safeUser is omit(user, "password")
-- {name: "Alice", age: 30, email: "alice@example.com"}

hasKey(obj, key) — Check if a field exists

say hasKey(user, "name")     -- true
say hasKey(user, "phone")    -- false

deepCopy(x) — Clone a value

Creates a complete independent copy of an object or list. Changes to the copy won't affect the original.

original is {scores: [90, 85, 92]}
copy is deepCopy(original)
push(copy.scores, 100)
say length(original.scores)   -- 3 (unchanged!)
say length(copy.scores)       -- 4

parseJSON(text) / toJSON(value) — JSON conversion

Convert between text (JSON format) and Quill values. JSON is a standard format for storing and exchanging data.

-- Text to object
data is parseJSON("{\"name\": \"Alice\", \"age\": 30}")
say data.name   -- "Alice"

-- Object to formatted text
say toJSON({name: "Bob", age: 25})

Type Checking

Sometimes you need to check what kind of value you're working with before doing something with it. These functions return yes or no.

isText(x) / isNumber(x) / isList(x) / isObject(x)

say isText("hello")     -- true
say isNumber(42)        -- true
say isList([1,2,3])    -- true
say isObject({a: 1})    -- true
say isNumber("42")      -- false (it's text, not a number!)

isNothing(x) / isFunction(x)

say isNothing(nothing)   -- true
say isNothing(0)         -- false (0 is a number, not nothing)
say isNothing("")        -- false (empty text is still text)
Practical use: Type checking is great for writing functions that accept different kinds of input:
to display value:
  if isText(value):
    say value
  otherwise if isList(value):
    say join(value, ", ")
  otherwise:
    say toText(value)

Files & Folders

Read, write, and manage files on your computer. These functions only work when running Quill with the CLI (quill run), not in the browser.

read(path) / write(path, content) — Basic file I/O

Read the entire contents of a file, or write content to a file (creates it if it doesn't exist, overwrites if it does).

-- Read a file
content is read("notes.txt")
say content

-- Write to a file
write("output.txt", "Hello from Quill!")

append_file(path, content) — Add to a file

Adds content to the end of a file without erasing what's already there. Like adding a new entry to a log.

append_file("log.txt", "User logged in at " + now())

readJSON(path) / writeJSON(path, data) — JSON files

Read and write JSON files directly. Quill handles the parsing and formatting for you.

-- Read a config file
config is readJSON("config.json")
say config.name

-- Save data to a file
writeJSON("users.json", [{name: "Alice"}, {name: "Bob"}])

readLines(path) / writeLines(path, list)

Read a file as a list of lines, or write a list of lines to a file.

todos is readLines("todo.txt")
for each todo in todos:
  say "- " + todo

fileExists(path) / exists(path) — Check if file exists

if fileExists("config.json"):
  config is readJSON("config.json")
otherwise:
  say "No config found, using defaults"

listFiles(dir) / listFilesDeep(dir) — List files

listFiles lists files in a directory. listFilesDeep includes all subdirectories too.

files is listFiles(".")
say "Files in current folder: " + join(files, ", ")

fileInfo(path) — Get file details

Returns an object with file size, modification date, and type.

info is fileInfo("photo.jpg")
say "Size: " + toText(info.size) + " bytes"
say "Modified: " + info.modified

copyFile(src, dest) / moveFile(src, dest) / deleteFile(path)

copyFile("report.pdf", "backup/report.pdf")
moveFile("old.txt", "archive/old.txt")
deleteFile("temp.txt")

makeDir(path) — Create folders

Creates a directory (and any parent directories that don't exist yet).

makeDir("output/reports/2026")

Path helpers: joinPath, fileName, fileExtension, parentDir

say joinPath("docs", "guide.txt")       -- "docs/guide.txt"
say fileName("/home/user/photo.jpg")    -- "photo.jpg"
say fileExtension("photo.jpg")          -- ".jpg"
say parentDir("/home/user/photo.jpg")   -- "/home/user"
say currentDir()                          -- your current working directory
say homePath()                            -- your home folder path

Date & Time

Functions for working with dates, times, and timestamps.

now() / today() — Current date/time

say now()     -- "2026-04-04T14:30:00.000Z" (full date and time)
say today()   -- "2026-04-04" (just the date)

timestamp() — Unix timestamp

Returns the current time as milliseconds since January 1, 1970. Useful for measuring how long something takes.

start is timestamp()
-- ... do some work ...
elapsed is timestamp() - start
say "Took " + toText(elapsed) + " ms"

formatDate(date, format) — Format a date

Formats a date string using a pattern. Available placeholders: YYYY (year), MM (month), DD (day), HH (hour), mm (minute), ss (second).

say formatDate(now(), "YYYY-MM-DD")       -- "2026-04-04"
say formatDate(now(), "MM/DD/YYYY")       -- "04/04/2026"
say formatDate(now(), "HH:mm:ss")         -- "14:30:00"

addDays(date, days) / diffDays(date1, date2)

-- What date is 7 days from now?
nextWeek is addDays(today(), 7)
say nextWeek

-- How many days between two dates?
say diffDays("2026-01-01", "2026-04-04")   -- 93

Web & HTTP

Fetch data from the internet and build web servers.

fetchJSON(url) — Get data from an API

Fetches data from a URL and automatically parses it as JSON. This is how you get data from web APIs.

data is await fetchJSON("https://api.example.com/users")
say data

postJSON(url, body) — Send data to an API

result is await postJSON("https://api.example.com/users", {
  name: "Alice",
  email: "alice@example.com"
})

putJSON(url, body) — Update an existing resource (full replacement)

result is await putJSON("https://api.example.com/users/1", {
  name: "Alice Updated",
  email: "alice@new.com"
})

patchJSON(url, body) — Partially update a resource

result is await patchJSON("https://api.example.com/users/1", {
  email: "alice@new.com"
})

deleteJSON(url) — Delete a resource

result is await deleteJSON("https://api.example.com/users/1")

createServer() — Build a web server

Creates an HTTP server with routing. This is how you build web applications and APIs with Quill.

server is createServer()

server.get("/", "Welcome to my site!")

server.get("/api/hello", {message: "Hello from Quill!"})

server.post("/api/data", with req, res:
  say "Got data!"
  give back {status: "ok"}
)

server.listen(3000)
say "Server running on port 3000"

serveStatic(dir) — Serve files

Serves static files (HTML, CSS, images) from a directory.

serveStatic("./public")

Crypto & Encoding

Functions for hashing, generating unique IDs, and encoding/decoding data.

hash(text, algorithm) — Hash a string

Creates a hash (fingerprint) of text. Useful for checksums and verification. Default algorithm is SHA-256.

say hash("hello")              -- "2cf24dba..." (SHA-256)
say hash("hello", "md5")    -- "5d41402a..." (MD5)

uuid() — Generate unique ID

Generates a random unique identifier. Perfect for giving each item a unique ID.

id is uuid()
say id   -- "550e8400-e29b-41d4-a716-446655440000"

encodeBase64(text) / decodeBase64(text)

Encode text to Base64 format and back. Base64 is a way to represent binary data as text.

encoded is encodeBase64("Hello, World!")
say encoded                      -- "SGVsbG8sIFdvcmxkIQ=="
say decodeBase64(encoded)       -- "Hello, World!"

encodeURL(text) / decodeURL(text)

Encode and decode text for safe use in URLs.

say encodeURL("hello world & more")   -- "hello%20world%20%26%20more"
say decodeURL("hello%20world")        -- "hello world"

encrypt(data, password) / decrypt(encrypted, password) — Symmetric encryption

Encrypt and decrypt data using a password. Uses AES-256-GCM under the hood. No imports needed.

encrypted is encrypt("secret data", "my-password")
say encrypted   -- encrypted string

decrypted is decrypt(encrypted, "my-password")
say decrypted   -- "secret data"

generateKeys() — Generate a public/private key pair

Creates an RSA key pair for asymmetric encryption. Returns an object with publicKey and privateKey.

keys is generateKeys()
say keys.publicKey    -- PEM-encoded public key
say keys.privateKey   -- PEM-encoded private key

hmac(data, secret, algorithm) — HMAC signature

Creates an HMAC (Hash-based Message Authentication Code) for verifying data integrity and authenticity. Default algorithm is SHA-256.

signature is hmac("my data", "my-secret")
say signature   -- hex-encoded HMAC

-- With a specific algorithm
sig is hmac("my data", "my-secret", "sha512")

randomBytes(size) — Generate random bytes

Generates a buffer of cryptographically secure random bytes. Useful for generating tokens, salts, and nonces.

token is randomBytes(32)
say toHex(token)   -- 64-character hex string

Patterns & RegExp

Search and replace using patterns (regular expressions). Patterns are like wildcards on steroids — they let you match complex text patterns.

matches(text, pattern, flags) — Find all matches

Returns a list of all parts of the text that match the pattern. The optional flags parameter accepts regex flags like "i" (case-insensitive) or "g" (global).

-- Find all numbers in text
say matches("I have 3 cats and 2 dogs", "\\d+")
-- ["3", "2"]

-- Find all email-like patterns
say matches("Contact alice@test.com or bob@test.com", "\\w+@\\w+\\.\\w+")
-- ["alice@test.com", "bob@test.com"]

-- Case-insensitive matching
say matches("Hello HELLO hello", "hello", "gi")
-- ["Hello", "HELLO", "hello"]

matchesPattern(text, pattern, flags) — Test if text matches

Returns yes or no based on whether the text matches the pattern. The optional flags parameter accepts regex flags.

say matchesPattern("hello123", "\\d+")   -- true (contains digits)
say matchesPattern("hello", "\\d+")      -- false (no digits)

replacePattern(text, pattern, replacement, flags)

Replaces all matches of a pattern with the replacement text. The optional flags parameter accepts regex flags.

-- Remove all digits
say replacePattern("abc123def456", "\\d+", "")
-- "abcdef"

-- Case-insensitive replace
say replacePattern("Hello HELLO", "hello", "hi", "gi")
-- "hi hi"

regex(pattern, flags) — Create a regex object

Creates a reusable regex object with the given pattern and flags.

pattern is regex("\\d+", "gi")
say pattern   -- a regex object

matchGroups(text, pattern, flags) — Get capture group matches

Returns the capture group matches from a pattern. Useful for extracting structured data from text.

result is matchGroups("2026-04-14", "(\\d{4})-(\\d{2})-(\\d{2})")
-- ["2026", "04", "14"]

matchFirst(text, pattern, flags) — Get the first match only

Returns just the first match of a pattern in the text, or nothing if no match is found.

first is matchFirst("Order #123 and #456", "#\\d+")
say first   -- "#123"

Drawing & Charts

Create drawings and charts programmatically. Draw shapes, text, and visualize data with bar charts, line charts, and pie charts.

drawCircle(x, y, size, color) — Draw a circle at position

drawCircle(200, 200, 50, "blue")

drawRect(x, y, width, height, color) — Draw a rectangle

drawRect(100, 100, 80, 60, "red")

drawLine(x1, y1, x2, y2, color) — Draw a line between two points

drawLine(0, 0, 400, 400, "green")

drawText(x, y, message, color) — Draw text at position

drawText(150, 50, "Hello Canvas!", "black")

fillBackground(color) — Fill the canvas background

fillBackground("white")

chart(data, type, title) — Draw a chart

Draws a chart from data. Supported types: "bar", "line", and "pie".

chart([
  {label: "Jan", value: 100},
  {label: "Feb", value: 150},
  {label: "Mar", value: 120}
], "bar", "Monthly Sales")

showCanvas(title) — Display the canvas

Displays the canvas with an optional title. Drawing commands are buffered and rendered when showCanvas() is called.

showCanvas("My Drawing")

Full Drawing Example

fillBackground("white")
drawCircle(200, 200, 50, "blue")
drawRect(100, 100, 80, 60, "red")
drawLine(0, 0, 400, 400, "green")
drawText(150, 50, "Hello Canvas!", "black")
showCanvas("My Drawing")
Canvas behavior: In Node.js, the canvas auto-opens in a browser. Drawing commands are buffered and rendered when showCanvas() is called (or automatically on exit). In the browser, drawing happens directly on an HTML canvas element.

System & Environment

Interact with the operating system, run commands, and access environment variables.

env(key) / env(key, default) / setEnv(key, value) — Environment variables

Read and set environment variables. These are settings stored by your operating system — things like API keys, database URLs, and configuration that changes between development and production.

-- Read an environment variable
say env("HOME")       -- "/Users/alice"
say env("PATH")       -- your system PATH

-- Provide a default value if the variable is not set
port is env("PORT", "3000")
say port   -- "3000" if PORT is not defined

-- Require a variable (throws an error if not set)
apiKey is env.require("API_KEY")
-- If API_KEY is not set, your program stops with an error message

-- Set an environment variable
setEnv("MY_APP", "production")
.env files: Quill automatically loads variables from a .env file in your project directory. Create a file called .env with one variable per line:
API_KEY=sk-abc123
DATABASE_URL=postgres://localhost/mydb
PORT=3000
These variables are then available via env("API_KEY") etc. Never commit your .env file to version control — add it to .gitignore.

args() — Command-line arguments

Returns a list of arguments passed to your program.

-- If you run: quill run app.quill hello world
say args()   -- ["hello", "world"]

run(command) — Run a shell command

Runs a command on your computer and returns the output as text.

say run("date")           -- current date from the system
say run("ls")             -- list files in current directory
say run("whoami")         -- your username

runAsync(command) — Run command in background

Like run but doesn't block your program while waiting.

output is await runAsync("curl https://api.example.com")
say output

platform() / cpuCount() / memory()

say platform()    -- "darwin" (macOS), "linux", or "win32"
say cpuCount()     -- 8
say memory()       -- {total: 17179869184, free: 5368709120}

exit(code) — Stop the program

if somethingWrong:
  say "Fatal error!"
  exit(1)   -- exit with error code

wait(ms) — Pause execution

say "Starting..."
wait(2000)   -- pause for 2 seconds
say "Done!"

Concurrency

Run multiple tasks at the same time for better performance.

parallel(...functions) — Run tasks simultaneously

Runs multiple functions at the same time and waits for all of them to finish. Returns a list of results.

-- Fetch multiple URLs at once instead of one by one
results is await parallel(
  with: fetchJSON("https://api.example.com/users"),
  with: fetchJSON("https://api.example.com/posts")
)

race(...functions) — First one wins

Runs multiple functions and returns the result of whichever finishes first.

delay(ms) — Async pause

Pauses for a number of milliseconds without blocking (unlike wait).

await delay(1000)   -- wait 1 second

Templates

template(text, data) — Fill in placeholders

Replaces {{key}} placeholders in text with values from a data object. Great for generating emails, HTML, or any text with dynamic content.

msg is template("Hello, {{name}}! You have {{count}} messages.", {
  name: "Alice",
  count: 5
})
say msg   -- "Hello, Alice! You have 5 messages."

Template Engine

Quill's built-in template engine provides functions for programmatically generating HTML. All output is escaped by default for XSS protection.

tag(name, attrsOrChildren, children) — Build an HTML tag

Creates an HTML element. The second argument can be an object of attributes or a string/array of children. If an attributes object is provided, pass children as the third argument.

tag("div", {class: "box"}, "Hello")
-- <div class="box">Hello</div>

tag("ul", [tag("li", "One"), tag("li", "Two")])
-- <ul><li>One</li><li>Two</li></ul>

page(options) — Generate a full HTML page

Produces a complete HTML document. Options: title, body, styles, scripts, lang, charset, viewport.

html is page({
  title: "My App",
  body: tag("h1", "Welcome"),
  styles: ["/css/app.css"],
  lang: "en"
})
say html

escapeHTML(str) — Escape HTML entities

Converts &, <, >, ", and ' to their HTML entity equivalents. Used internally by tag(), but available for manual use.

say escapeHTML("<script>alert('xss')</script>")
-- "&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;"

eachItem(items, fn) — Map items to HTML

Applies a function to each item in a list, joins the resulting HTML strings, and returns them as a single string.

fruits is ["Apple", "Banana", "Cherry"]
list is tag("ul", eachItem(fruits, with f: tag("li", f)))
say list
-- <ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>

showIf(condition, content) — Conditional rendering

Returns content when condition is true, or an empty string when false.

isAdmin is yes
html is showIf(isAdmin, tag("span", {class: "badge"}, "Admin"))
say html   -- <span class="badge">Admin</span>

layout(name, templateFn) — Register a layout

Registers a reusable layout template that can be rendered later with renderLayout().

layout("main", with data:
  page({
    title: data.title,
    body: tag("div", {class: "wrapper"}, [
      tag("header", tag("h1", data.title)),
      tag("main", data.content),
      tag("footer", "&copy; 2026")
    ])
  })
)

renderLayout(name, data) — Render a layout

Renders a previously registered layout with the provided data object.

html is renderLayout("main", {
  title: "About Us",
  content: tag("p", "We build great software.")
})
say html

partial(name, templateFn) — Register/render partials

Register a reusable HTML fragment. Call with a function to register; call with just a name to render.

-- Register a partial
partial("userCard", with user:
  tag("div", {class: "card"}, [
    tag("h3", user.name),
    tag("p", user.email)
  ])
)

-- Use the partial
html is partial("userCard", {name: "Alice", email: "alice@example.com"})

raw(str) — Bypass escaping

Marks a string as safe HTML, bypassing automatic escaping. Use with caution — only for content you fully trust.

html is tag("div", raw("<strong>Already safe</strong>"))
say html   -- <div><strong>Already safe</strong></div>

Shorthand tag builders

Pre-filled versions of tag() for common HTML elements:

-- Available shorthand builders:
div()  span()  h1()  h2()  h3()  p()  a()  img()
ul()  ol()  li()  form()  input()  button()  label()  textarea()
table()  tr()  th()  td()
nav()  header()  footer()  section()  article()  main()

-- Usage is identical to tag():
div({class: "container"}, [
  h1("Hello"),
  p({class: "lead"}, "Welcome to Quill.")
])

Database (SQLite)

Store and query data using SQLite, a simple file-based database. Requires the better-sqlite3 npm package (quill add better-sqlite3).

openDB(path) / closeDB(db)

db is openDB("mydata.db")
-- ... use the database ...
closeDB(db)

execute(db, sql, params) — Run a command

execute(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)", [])
execute(db, "INSERT INTO users (name, age) VALUES (?, ?)", ["Alice", 30])

query(db, sql, params) — Fetch data

users is query(db, "SELECT * FROM users WHERE age > ?", [18])
for each user in users:
  say user.name + " is " + toText(user.age)

Iterators & Lazy Evaluation

Quill includes a lazy evaluation system that lets you process large (even infinite) data sets efficiently. Values are only computed when they are actually needed. All of these methods are available on lazy iterators created by generators or the range() function.

Creating iterators

-- range() creates a lazy iterator
nums is range(1, 1000000)

-- Generator functions create lazy iterators
to fibonacci:
  a is 0
  b is 1
  loop:
    yield a
    temp is a
    a is b
    b is temp + b

filter — Keep items that match a condition

Returns a new lazy iterator that only includes items where the function returns yes.

evens is range(1, 100) | filter with n: n % 2 is 0

map_list — Transform each item

Returns a new lazy iterator where each item has been transformed by the function.

squares is range(1, 10) | map_list with n: n * n

take n — Keep only the first n items

first5 is fibonacci() | take 5 | collect
say first5   -- [0, 1, 1, 2, 3]

skip n — Skip the first n items

result is range(1, 20) | skip 5 | take 3 | collect
say result   -- [6, 7, 8]

takeWhile — Keep items while a condition holds

result is range(1, 100) | takeWhile with n: n is less than 10 | collect
say result   -- [1, 2, 3, 4, 5, 6, 7, 8, 9]

skipWhile — Skip items while a condition holds

result is [1, 2, 3, 10, 2] | skipWhile with n: n is less than 5 | collect
say result   -- [10, 2]

zip — Combine two iterators into pairs

names are ["Alice", "Bob", "Charlie"]
scores are [95, 87, 92]
pairs is zip(names, scores) | collect
say pairs   -- [["Alice", 95], ["Bob", 87], ["Charlie", 92]]

enumerate — Add an index to each item

items are ["apple", "banana", "cherry"]
result is items | enumerate | collect
say result   -- [[0, "apple"], [1, "banana"], [2, "cherry"]]

flatten — Flatten nested iterators

nested are [[1, 2], [3, 4], [5]]
result is nested | flatten | collect
say result   -- [1, 2, 3, 4, 5]

collect — Gather all items into a list

This is a terminal operation. It consumes the lazy iterator and produces a concrete list.

result is range(1, 5) | collect
say result   -- [1, 2, 3, 4, 5]

reduce — Combine all items into one value

total is range(1, 10) | reduce with a, b: a + b
say total   -- 55

first — Get the first item

result is range(1, 1000000) | filter with n: n % 7 is 0 | first
say result   -- 7

last — Get the last item

result is range(1, 10) | last
say result   -- 10

any — True if any item matches

hasNegative is [1, -3, 5] | any with n: n is less than 0
say hasNegative   -- yes

every — True if all items match

allPositive is [1, 2, 3] | every with n: n is greater than 0
say allPositive   -- yes

Chaining it all together

The real power comes from combining these operations into pipelines. Nothing is computed until a terminal operation (collect, reduce, first, last, any, or every) is called:

-- Process a million numbers without using a million items of memory
result is range(1, 1000000)
  | filter with n: n % 2 is 0
  | map_list with n: n * n
  | take 10
  | collect

say result   -- [4, 16, 36, 64, 100, 144, 196, 256, 324, 400]
Why lazy? Without lazy evaluation, range(1, 1000000) would create a list of a million numbers in memory. With lazy evaluation, items are produced one at a time and only as many as needed. The take 10 above means only 10 items are ever computed, no matter how large the range.

Recipes

Common tasks showing how to combine multiple functions together.

Recipe: Process a list of names
names are ["  alice  ", "BOB", "  Charlie "]

-- Clean up, capitalize, sort, and display
cleaned is names
  | map_list(with n: trim(n))
  | map_list(with n: capitalize(lower(n)))
  | sort

say join(cleaned, ", ")   -- "Alice, Bob, Charlie"
Recipe: Read and process a CSV file
-- Read a CSV file and get the data
raw is read("data.csv")
rows is lines(raw)
headers is split(rows[0], ",")

say "Columns: " + join(headers, ", ")
say "Total rows: " + toText(length(rows) - 1)
Recipe: Simple REST API
users are []

server is createServer()

server.get("/api/users", users)

server.post("/api/users", with req, res:
  push(users, {id: uuid(), name: "New User"})
  give back {status: "created"}
)

server.listen(3000)
say "API running on http://localhost:3000"
Recipe: File backup script
-- Back up all .quill files to a backup folder
makeDir("backup")
files is listFiles(".")
quillFiles is filter(files, with f: endsWith(f, ".quill"))

for each file in quillFiles:
  copyFile(file, joinPath("backup", file))
  say "Backed up: " + file

say "Done! Backed up " + toText(length(quillFiles)) + " files"

Tagged Template Tags

Tagged templates let you prefix a string with a function name. The function receives the string parts and the interpolated values separately, so it can process them safely — for example, escaping SQL parameters to prevent injection, or sanitizing HTML to prevent cross-site scripting. Quill ships with three built-in tags.

query — Safe SQL queries

The query tag automatically escapes interpolated values so they are safe to use in SQL. You never have to worry about SQL injection when you use this tag.

minAge is 18
result is query`SELECT * FROM users WHERE age > {minAge}`
-- The value 18 is passed as a parameter, not concatenated into the string

name is "O'Brien"
result is query`SELECT * FROM users WHERE name = {name}`
-- The apostrophe is safely escaped

html — Safe HTML rendering

The html tag sanitizes interpolated values so that any HTML special characters are escaped. This prevents malicious scripts from being injected into your pages.

title is "Hello <World>"
body is "This is <b>safe</b>"
page is html`<h1>{title}</h1><p>{body}</p>`
-- Output: <h1>Hello &lt;World&gt;</h1><p>This is &lt;b&gt;safe&lt;/b&gt;</p>

css — Scoped CSS generation

The css tag lets you write CSS with interpolated values for dynamic theming. Values are validated to prevent CSS injection.

spacing is 16
color is "#333"
styles is css`
  .card:
    padding: {spacing}px;
    color: {color};
`
Custom tags: You can write your own tag functions. A tag function receives two arguments — a list of string parts and a list of interpolated values — and returns whatever you want.

Result Type Helpers

The Result type represents an operation that can either succeed or fail. Instead of throwing exceptions, functions return Success(value) or Error(message). This makes error handling explicit and predictable.

Why use Result instead of try/catch? With try/catch, you might forget to handle an error and your program crashes. With Result, the type system reminds you — you cannot use the value without first checking whether the operation succeeded.

Success(value) — Wrap a successful value

Creates a Result that represents a successful outcome. The value inside can be anything — a number, text, object, or list.

to divide a as number, b as number -> Result of number:
  if b is 0:
    give back Error("cannot divide by zero")
  give back Success(a / b)

result is divide(10, 2)
say result.ok      -- yes
say result.value   -- 5

Error(message) — Wrap an error

Creates a Result that represents a failure. The message describes what went wrong.

result is divide(10, 0)
say result.ok       -- no
say result.error    -- "cannot divide by zero"

Working with Results

Check whether a Result is a success or an error, then use the appropriate field.

result is loadUser(42)

if result.ok:
  say "Found user: {result.value.name}"
otherwise:
  say "Error: {result.error}"

-- Or use the ? operator for auto-propagation
to loadProfile id as number -> Result of Profile:
  user is loadUser(id)?    -- returns Error early if loadUser fails
  give back Success(user.profile)

-- Or use try as an expression with a fallback
name is try loadUser(42) otherwise "anonymous"

Auth (Authentication and Security)

The Auth package gives you everything you need to handle user authentication, password hashing, tokens (like login tickets), and sessions. Think of it like the security system for your application: it checks IDs at the door and makes sure only the right people get in.

New to authentication? When someone logs in to a website, the server needs to (1) check their password safely, (2) give them a "ticket" (token) that proves they are logged in, and (3) remember them for a while (session). The Auth package handles all three.

Auth.hash(password) — Hash a password

Turns a plain text password into a scrambled version that is safe to store in a database. Even if someone steals your database, they cannot reverse the hash to get the original password. Think of it like putting a letter through a shredder, you can verify it was the right letter, but you cannot unshred it.

-- When a user signs up, hash their password before saving
password is "mySecretPassword123"
hashed is Auth.hash(password)
say hashed   -- "$2b$10$xK8f..." (a long scrambled string)

-- Save the hashed version to your database
saveUser({name: "Alice", password: hashed})

Auth.verify(password, hash) — Check a password

Compares a plain text password against a hashed version. Returns true if they match, false otherwise. Use this when a user logs in.

-- When a user logs in, check their password
user is findUser("alice@example.com")
isCorrect is Auth.verify("mySecretPassword123", user.password)

if isCorrect:
  say "Welcome back, {user.name}!"
otherwise:
  say "Wrong password. Try again."

Auth.createToken(payload, secret, expiresIn) — Create a JWT

Creates a JSON Web Token (JWT), a small encrypted "ticket" that proves a user is logged in. The payload is the data you want to include (like the user ID), the secret is a private key only your server knows, and expiresIn is how long the token is valid.

-- After successful login, give the user a token
token is Auth.createToken(
  {userId: user.id, role: "admin"},
  "my-secret-key",
  "24h"
)
say "Your token: {token}"

-- Send this token back to the user
-- They will include it in future requests to prove who they are

Auth.verifyToken(token, secret) — Verify a JWT

Checks if a token is valid and has not expired. Returns the payload data if valid, or throws an error if the token is fake or expired.

-- When the user makes a request, check their token
try:
  payload is Auth.verifyToken(token, "my-secret-key")
  say "User {payload.userId} is authenticated"
  say "Role: {payload.role}"
catch error:
  say "Invalid or expired token. Please log in again."

Auth.session(options) — Manage sessions

Creates a session manager that remembers users across requests. Think of it like a wristband at an event, once you get one, you don't need to show your ID again until the wristband expires.

-- Set up session management for your server
sessions is Auth.session({
  secret: "my-session-secret",
  maxAge: "7d",           -- sessions last 7 days
  secure: true            -- only send over HTTPS
})

-- Use it in your server
server.use(sessions)

-- In a login route: create a session
server.post("/login", with req, res:
  user is authenticate(req.body)
  req.session.userId is user.id
  give back {message: "Logged in!"}
)

-- In a protected route: check the session
server.get("/profile", with req, res:
  if req.session.userId:
    user is getUser(req.session.userId)
    give back user
  otherwise:
    give back {error: "Not logged in"}
)

ORM (Object-Relational Mapping)

The ORM package lets you work with databases using Quill code instead of writing raw SQL queries. Think of it like a translator between your Quill objects and the database tables, you describe your data as Quill objects, and the ORM figures out how to store and retrieve them from the database.

New to databases? A database is like a giant spreadsheet where your application stores data permanently (users, posts, orders, etc.). An ORM lets you interact with that spreadsheet using Quill code instead of a separate database language called SQL.

DB.connect(config) — Connect to a database

Opens a connection to your database. You need to do this once when your application starts.

-- Connect to a PostgreSQL database
db is DB.connect({
  type: "postgres",
  host: "localhost",
  port: 5432,
  database: "my_app",
  user: "admin",
  password: "secret"
})

-- Or connect to SQLite (simpler, file-based)
db is DB.connect({
  type: "sqlite",
  file: "data.db"
})

DB.model(name, schema) — Define a model

A model describes what your data looks like, the fields, their types, and any rules. Think of it like a form template: it defines what information you need to collect.

-- Define a User model
User is DB.model("User", {
  name: {type: "text", required: true},
  email: {type: "text", required: true, unique: true},
  age: {type: "number"},
  role: {type: "text", default: "user"},
  createdAt: {type: "date", default: now()}
})

-- Define a Post model
Post is DB.model("Post", {
  title: {type: "text", required: true},
  body: {type: "text"},
  author: {type: "text"},
  likes: {type: "number", default: 0},
  published: {type: "boolean", default: false}
})

DB.migrate() — Create or update tables

Syncs your models with the actual database. If the tables don't exist yet, it creates them. If you added new fields to a model, it updates the tables.

-- After defining your models, run migrate
DB.migrate()
say "Database tables are ready!"

CRUD Operations (Create, Read, Update, Delete)

Once you have a model, you can create, find, update, and delete records:

-- CREATE: add a new user
newUser is User.create({
  name: "Alice",
  email: "alice@example.com",
  age: 28
})
say "Created user: {newUser.name} (id: {newUser.id})"

-- READ: find users
allUsers is User.find()                        -- get everyone
admins is User.find({role: "admin"})            -- filter by role
alice is User.findOne({email: "alice@example.com"})  -- find one

-- UPDATE: change a user's data
User.update({email: "alice@example.com"}, {age: 29})

-- DELETE: remove a user
User.delete({email: "old-user@example.com"})

Query Builder

For more complex queries, chain methods together to build exactly the query you need:

-- Find the 10 most recent admin users
recentAdmins is User
  .where({role: "admin"})
  .orderBy("createdAt", "desc")
  .limit(10)

-- Find published posts with more than 100 likes
popularPosts is Post
  .where({published: true})
  .where("likes", ">", 100)
  .orderBy("likes", "desc")
  .limit(20)

DB.raw() — Raw SQL

If you need to write SQL directly, you can:

results is DB.raw("SELECT * FROM users WHERE age > $1", [21])

DB.transaction() — Transactions

A transaction groups multiple database operations so they either all succeed or all fail. Think of it like transferring money, you want the debit and credit to happen together, not one without the other.

-- Transfer money between accounts
DB.transaction(with tx:
  tx.update("accounts", {id: 1}, {balance: balance - 100})
  tx.update("accounts", {id: 2}, {balance: balance + 100})
  say "Transfer complete!"
)

Validation

The Validation package helps you check that data is correct before you use it. Think of it like a bouncer at a club, it checks that everyone meets the requirements before letting them in. Is the email address actually an email? Is the age a positive number? Is the name at least 2 characters long? Validation answers these questions.

Validate.schema(rules) — Define validation rules

Creates a set of rules that data must follow. Each field gets one or more rules.

-- Define rules for user registration
userSchema is Validate.schema({
  name: {required: true, minLength: 2, maxLength: 50},
  email: {required: true, email: true},
  age: {required: true, min: 13, max: 120},
  website: {url: true},
  password: {required: true, minLength: 8, pattern: "[A-Z]"},
  role: {oneOf: ["user", "admin", "moderator"]}
})

Available rules

Rule What it checks Example
requiredField must be present and not emptyrequired: true
minLengthText must be at least this many charactersminLength: 2
maxLengthText must be no more than this many charactersmaxLength: 100
minNumber must be at least this valuemin: 0
maxNumber must be no more than this valuemax: 999
emailMust be a valid email addressemail: true
urlMust be a valid URLurl: true
patternMust match a regular expressionpattern: "[0-9]+"
oneOfMust be one of the listed valuesoneOf: ["a", "b"]
customYour own validation functioncustom: with v: v > 0
arrayOfEach item in the list must match a schemaarrayOf: {type: "text"}

schema.check(data) — Validate data

Runs the rules against actual data and tells you what (if anything) is wrong.

-- Check valid data
result is userSchema.check({
  name: "Alice",
  email: "alice@example.com",
  age: 28,
  password: "SecurePass1",
  role: "user"
})
say result.valid   -- true
say result.errors  -- [] (empty -- no problems)

-- Check invalid data
result is userSchema.check({
  name: "",
  email: "not-an-email",
  age: 5,
  password: "short"
})
say result.valid   -- false
say result.errors
-- ["name: must be at least 2 characters",
--  "email: must be a valid email address",
--  "age: must be at least 13",
--  "password: must be at least 8 characters"]

Quick validators

For simple one-off checks, use these shorthand functions:

say Validate.isEmail("alice@example.com")   -- true
say Validate.isEmail("not-valid")           -- false

say Validate.isUrl("https://quill.dev")    -- true
say Validate.isUrl("just some text")       -- false

say Validate.isNumber(42)                  -- true
say Validate.isNumber("hello")             -- false

Logging

The Logging package helps you record what your application is doing. Think of it like a ship's log, it keeps a record of important events so you can look back and understand what happened. Good logging is essential for finding and fixing bugs in production.

Why not just use say? The say keyword prints text to the screen, which is fine for simple programs. But for real applications, you need different levels of importance (is this an error or just info?), timestamps, structured data, and the ability to send logs to files or monitoring services. That is what the Logging package provides.

Log.create(options) — Create a logger

Creates a logger with the settings you want. You can choose the minimum level, the output format, and where logs go.

-- Create a logger for your application
logger is Log.create({
  level: "info",         -- minimum level to show (debug/info/warn/error/fatal)
  format: "pretty",      -- "pretty" for console, "json" for production
  name: "my-app"         -- label for your logs
})

Log levels

Each level represents a different severity. From least to most severe:

logger.debug("Loading config file...")     -- Detailed info for developers
logger.info("Server started on port 3000")  -- Normal operational info
logger.warn("Disk space is getting low")   -- Something is not ideal
logger.error("Failed to connect to DB")    -- Something went wrong
logger.fatal("Out of memory, shutting down") -- Critical failure

When you set the level to "info", you will see info, warn, error, and fatal messages, but not debug messages. Set it to "debug" during development to see everything.

Structured logging

You can attach data to your log messages. This is especially useful in production where you want to search and filter logs.

-- Log with extra data
logger.info("User logged in", {userId: 42, ip: "192.168.1.1"})
-- Output (pretty): [INFO] User logged in {userId: 42, ip: "192.168.1.1"}
-- Output (json):   {"level":"info","msg":"User logged in","userId":42,"ip":"192.168.1.1","time":"..."}

logger.error("Payment failed", {orderId: "abc-123", amount: 49.99, reason: "card declined"})

logger.child() — Create a child logger

Creates a new logger that inherits all settings but adds extra default fields. Useful for adding context like a request ID.

-- Create a child logger for a specific request
requestLogger is logger.child({requestId: "req-abc-123"})

requestLogger.info("Processing request")
-- Output: [INFO] Processing request {requestId: "req-abc-123"}

requestLogger.info("Fetching user data")
-- Output: [INFO] Fetching user data {requestId: "req-abc-123"}
-- The requestId is automatically included in every log

Log.middleware() — HTTP request logging

Automatically logs every HTTP request that comes into your server, including the method, URL, status code, and how long it took.

-- Add logging to your server
server.use(Log.middleware())

-- Now every request is automatically logged:
-- [INFO] GET /api/users 200 12ms
-- [INFO] POST /api/login 200 45ms
-- [WARN] GET /api/missing 404 3ms
-- [ERROR] POST /api/crash 500 2ms

Complete example

-- Set up logging for a production application
logger is Log.create({
  level: "info",
  format: "json",
  name: "my-api"
})

logger.info("Application starting...")

try:
  db is DB.connect(config)
  logger.info("Connected to database", {host: config.host})
catch error:
  logger.fatal("Cannot connect to database", {error: error.message})

server.use(Log.middleware())
server.listen(3000)
logger.info("Server ready", {port: 3000})

Discord Bots

Quill makes it easy to build Discord bots using the discord.js npm package. Use the use statement to import the library and the on event handler syntax for clean, readable bot code.

Quick start: Run quill discord my-bot to scaffold a full Discord bot project with package.json, .env, and a starter bot.quill file.

Setting up a Discord bot

use "discord.js" as Discord

intents is [Discord.GatewayIntentBits.Guilds, Discord.GatewayIntentBits.GuildMessages, Discord.GatewayIntentBits.MessageContent]

options is {intents: intents}
bot is new Discord.Client(options)

Handling events

Use the on keyword to register event handlers. The with keyword introduces callback parameters.

bot on "ready" with:
  say "Bot is online as {bot.user.tag}!"

bot on "messageCreate" with msg:
  if msg.author.bot:
    give back nothing

  if msg.content is "!hello":
    msg.reply("Hello from Quill!")

  if startsWith(msg.content, "!say "):
    text is msg.content.slice(5)
    msg.reply(text)

Starting the bot

bot.login("YOUR_BOT_TOKEN")

For production, use environment variables instead of hardcoding tokens:

bot.login(process.env.DISCORD_TOKEN)

Helper function

Quill also provides a createBot helper that creates a Discord client with common intents pre-configured:

bot is createBot(process.env.DISCORD_TOKEN)

Buffer & Encoding

Functions for converting between buffers, text, Base64, and hex representations. Useful when working with binary data, file contents, or network protocols.

toBuffer(text) — Convert text to a buffer

Converts a string into a binary buffer (Uint8Array). Useful for binary operations and cryptography.

buf is toBuffer("hello")
say buf   -- Uint8Array [104, 101, 108, 108, 111]

fromBuffer(buffer) — Convert buffer to text

Converts a binary buffer back into a string.

buf is toBuffer("hello")
text is fromBuffer(buf)
say text   -- "hello"

toBase64(data) / fromBase64(text) — Base64 encoding

Encode data to Base64 and decode Base64 back to the original. Works with both text and buffers.

encoded is toBase64("Hello, World!")
say encoded   -- "SGVsbG8sIFdvcmxkIQ=="

original is fromBase64(encoded)
say original   -- "Hello, World!"

toHex(data) / fromHex(text) — Hex encoding

Encode data as a hexadecimal string and decode hex strings back to the original data.

hex is toHex("hello")
say hex   -- "68656c6c6f"

original is fromHex(hex)
say original   -- "hello"

CLI Tools

Built-in functions for building command-line tools. Parse arguments, flags, and add colored output with no imports needed.

Quick start: Run quill cli my-tool to scaffold a CLI tool project with argument parsing and help text.

arg(index) — Get a positional argument

Returns the positional argument at the given index (0-based). Returns nothing if the index is out of bounds.

-- If you run: quill run tool.quill hello world
say arg(0)   -- "hello"
say arg(1)   -- "world"
say arg(2)   -- nothing

args() — Get all arguments

Returns a list of all positional arguments passed to the program.

-- If you run: quill run tool.quill a b c
say args()   -- ["a", "b", "c"]

flag(name) — Get a named flag value

Returns the value of a named flag (e.g. --output file.txt). Returns nothing if the flag is not present.

-- If you run: quill run tool.quill --output result.txt
output is flag("output")
say output   -- "result.txt"

hasFlag(name) — Check if a flag is present

Returns yes or no depending on whether the flag was passed.

-- If you run: quill run tool.quill --verbose
if hasFlag("verbose"):
  say "Verbose mode enabled"

colors — Colored terminal output

An object with methods for coloring terminal text. Available colors: red, green, blue, yellow, cyan, magenta, gray, bold, dim, underline.

say colors.green("Success!")
say colors.red("Error: something went wrong")
say colors.bold("Important message")
say colors.yellow("Warning: proceed with caution")

exitWith(code) — Exit with a status code

Exits the program with the given status code. Use 0 for success and any non-zero number for failure.

if hasFlag("help"):
  say "Usage: my-tool [options]"
  exitWith(0)

if arg(0) is nothing:
  say colors.red("Error: missing required argument")
  exitWith(1)

Cron Jobs

Schedule recurring tasks using natural English syntax. No cron expression strings needed — just say how often you want something to run.

Syntax: every N seconds/minutes/hours

Use the every keyword followed by a number and a time unit to schedule a recurring block of code.

every 5 seconds:
  say "tick"

every 30 minutes:
  data is await fetch("https://api.example.com/health")
  say "Health check: " + data.status

every 1 hour:
  say "Hourly report"
How it works: Quill compiles every blocks into setInterval calls with the appropriate millisecond interval. The task runs in the background and does not block the rest of your program. You can use await inside cron blocks for async operations.

HTTPS Server

Create secure HTTPS servers with TLS certificates. Works just like createServer() but with encryption enabled. The createSecureServer function wraps Node's https.createServer and gives you the same routing API you already know from Quill's HTTP server.

createSecureServer(options) — Create an HTTPS server

Creates a server that uses TLS encryption. You provide paths to your SSL certificate and private key.

Parameters:

Returns: A server object with .get(), .post(), .put(), .delete(), .use(), and .listen() methods.

server is createSecureServer({
  cert: readFile("cert.pem"),
  key: readFile("key.pem")
})

server.get("/", "Hello over HTTPS!")
server.listen(443)
Generating self-signed certs for development: Run this in your terminal to create a certificate valid for 365 days:
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
This creates cert.pem and key.pem in your current directory. Your browser will show a security warning — that is expected for self-signed certs.

Routes with handlers

Define GET, POST, PUT, and DELETE routes with handler functions. Handlers receive a request object and return a response.

server is createSecureServer({
  cert: readFile("cert.pem"),
  key: readFile("key.pem")
})

-- GET route with a simple string response
server.get("/", "Welcome to the secure API")

-- GET route with a handler function
server.get("/users/:id", with req:
  user is await db.find("users", req.params.id)
  respond { status: 200, json: user }
)

-- POST route that reads the request body
server.post("/users", with req:
  newUser is await db.insert("users", req.body)
  respond { status: 201, json: newUser }
)

-- DELETE route
server.delete("/users/:id", with req:
  await db.remove("users", req.params.id)
  respond { status: 204 }
)

server.listen(443)

Middleware

Use .use() to add middleware that runs before your route handlers. Middleware can inspect requests, add headers, or reject unauthorized access.

-- Logging middleware
server.use(with req, next:
  say req.method + " " + req.url
  next()
)

-- Auth middleware
server.use(with req, next:
  token is req.headers["authorization"]
  if token is nothing:
    respond { status: 401, json: { error: "Unauthorized" } }
  otherwise:
    next()
)

-- CORS middleware
server.use(with req, next:
  req.setHeader("Access-Control-Allow-Origin", "*")
  next()
)

Using Let's Encrypt certificates

In production, use real certificates from Let's Encrypt. After running certbot, point your server at the generated files:

-- Let's Encrypt certs (typically in /etc/letsencrypt/live/yourdomain/)
server is createSecureServer({
  cert: readFile("/etc/letsencrypt/live/example.com/fullchain.pem"),
  key: readFile("/etc/letsencrypt/live/example.com/privkey.pem")
})

server.get("/", "Hello from production!")
server.listen(443)
Certificate renewal: Let's Encrypt certificates expire every 90 days. Set up a cron job (certbot renew) and restart your server to pick up the new certs. You can use Quill's every block to watch for certificate changes and reload automatically.

Secure Storage

Browser-side encrypted storage for sensitive data. Works like localStorage but encrypts values before storing them. Under the hood, SecureStorage uses AES-256-GCM authenticated encryption — values are encrypted with a key derived from your password via PBKDF2 (100,000 iterations). The IV and auth tag are stored alongside the ciphertext.

SecureStorage.set(key, value, password)

Encrypts and stores a value in the browser's local storage.

Parameters:

SecureStorage.set("token", "sk-abc123", "user-password")

SecureStorage.get(key, password)

Retrieves and decrypts a value from secure storage. Returns nothing if the key does not exist or the password is wrong.

Parameters:

Returns: The decrypted value, or nothing on failure.

token is SecureStorage.get("token", "user-password")
say token   -- "sk-abc123"

SecureStorage.remove(key)

Removes an item from secure storage.

SecureStorage.remove("token")

SecureStorage.clear()

Removes all items from secure storage.

SecureStorage.clear()

Practical example: storing and retrieving an API token

-- After the user logs in, store their API token securely
say "Enter a password to protect your token:"
password is await promptPassword()

SecureStorage.set("api-token", user.apiToken, password)
say "Token saved securely."

-- Later, retrieve the token
password is await promptPassword()
token is SecureStorage.get("api-token", password)

if token is nothing:
  say "Wrong password or token not found."
otherwise:
  say "Token retrieved, making API call..."
  result is await fetch("https://api.example.com/data", {
    headers: { authorization: "Bearer " + token }
  })

Error handling when password is wrong

When you provide the wrong password, SecureStorage.get() returns nothing rather than throwing an error. This means AES-GCM authentication failed and the data could not be decrypted. Always check the return value:

result is SecureStorage.get("api-token", userInput)

if result is nothing:
  say "Decryption failed. Check your password and try again."
otherwise:
  say "Decrypted value: " + result
When to use SecureStorage vs server-side storage: Use SecureStorage when you need to keep secrets on the client (e.g., API tokens, encryption keys) without exposing them in plain text in localStorage. However, if your threat model includes malicious JavaScript on the same origin, server-side storage with HTTP-only cookies is stronger. SecureStorage is best for protecting data at rest on the user's device — for example, an offline-first app that stores credentials locally.

WebRTC

Built-in peer-to-peer communication for real-time audio, video, and data channels. No external libraries needed.

createPeer(options) — Create a peer connection

Creates a new WebRTC peer connection. You can optionally pass ICE server configuration.

peer is createPeer({
  iceServers: [{urls: "stun:stun.l.google.com:19302"}]
})

PeerConnection events

Use the on keyword to handle peer connection events.

peer on "connect" with:
  say "Connected to peer!"

peer on "data" with message:
  say "Received: " + message

peer on "stream" with stream:
  say "Got media stream"

peer on "close" with:
  say "Connection closed"

Sending data

peer.send("Hello from Quill!")
peer.send({type: "chat", text: "Hi there"})

Signaling

WebRTC requires a signaling mechanism to exchange connection info between peers. Use peer.createOffer() and peer.createAnswer() to generate SDP descriptions, and peer.signal(data) to process incoming signals.

-- Initiator side
offer is await peer.createOffer()
-- Send offer to the other peer via your signaling server

-- Receiver side
peer.signal(offer)
answer is await peer.createAnswer()
-- Send answer back to initiator

Signal Protocol Primitives

Advanced cryptographic primitives for building secure messaging protocols, end-to-end encryption, and high-security applications. These functions wrap battle-tested implementations from Node.js crypto module.

hkdf(inputKey, salt, info, outputLength) — HMAC-based Key Derivation

Derives a cryptographic key from input keying material using HKDF (RFC 5869). Essential for key derivation in protocols like Signal’s Double Ratchet.

-- Derive an encryption key from a shared secret
derived is hkdf(sharedSecret, salt, "encryption-key", 32)

-- Derive multiple keys from same input
encKey is hkdf(master, salt, "enc", 32)
macKey is hkdf(master, salt, "mac", 32)

diffieHellman(myPrivateKey, theirPublicKey) — X25519 Shared Secret

Computes an X25519 Diffie-Hellman shared secret. Used for key agreement in end-to-end encryption.

-- Generate key pairs for two parties
myKeys is generateX25519Keys()
theirKeys is generateX25519Keys()

-- Both sides compute the same shared secret
shared1 is diffieHellman(myKeys.privateKey, theirKeys.publicKey)
shared2 is diffieHellman(theirKeys.privateKey, myKeys.publicKey)
-- shared1 and shared2 are identical

generateX25519Keys() — Generate X25519 Key Pair

Generates a new X25519 key pair for use with diffieHellman(). Returns an object with publicKey and privateKey as Buffers.

keys is generateX25519Keys()
say toHex(keys.publicKey)   -- the public key to share
-- Keep keys.privateKey secret!

argon2(password, salt, options) — Memory-Hard Password Hashing

Hashes a password using Argon2id, the recommended algorithm for password storage. This is an async operation. Options can specify memoryCost, timeCost, and parallelism.

-- Hash a password for storage
hashed is await argon2(password, salt)

-- With custom parameters
hashed is await argon2(password, salt, {
  memoryCost: 65536,
  timeCost: 3,
  parallelism: 4
})

argon2Verify(hashed, password) — Verify Password

Verifies a password against an Argon2 hash. Returns true or false.

isValid is await argon2Verify(storedHash, userPassword)
if isValid:
  say "Login successful"

bcryptHash(password, rounds) — bcrypt Password Hashing

Hashes a password using bcrypt with configurable rounds. Returns a string containing the algorithm identifier, cost factor, salt, and hash — everything needed to verify later.

Parameters:

Returns: A bcrypt hash string (e.g., $2b$12$...).

hashed is await bcryptHash(password, 12)

bcryptVerify(hashed, password) — Verify bcrypt Password

Verifies a password against a bcrypt hash. The round count and salt are read from the hash string automatically.

Parameters:

Returns: true if the password matches, false otherwise.

isValid is await bcryptVerify(storedHash, userPassword)

Full signup and login flow

-- SIGNUP: hash the password and store it
signup is with username, password:
  hashed is await bcryptHash(password, 12)
  await db.insert("users", {
    username: username,
    passwordHash: hashed
  })
  say "Account created for " + username

-- LOGIN: retrieve the hash and verify
login is with username, password:
  user is await db.findOne("users", { username: username })
  if user is nothing:
    say "User not found"
    respond false

  isValid is await bcryptVerify(user.passwordHash, password)
  if isValid:
    say "Login successful"
    respond true
  otherwise:
    say "Incorrect password"
    respond false
bcrypt vs Argon2 — which should you use?
  • Argon2 (use argon2() / argon2Verify()) is the modern recommendation. It is memory-hard, which makes GPU-based attacks expensive. Use Argon2 for new projects.
  • bcrypt is battle-tested and widely supported. Use it when you need compatibility with existing systems that already store bcrypt hashes, or when working with libraries/APIs that expect bcrypt format.
Both are far better than plain SHA-256 or MD5 for password storage. Never store passwords with a simple hash function.

hmacBuffer(data, key, algorithm) — HMAC as Buffer

Like hmac() but returns a raw Buffer instead of a hex string. Useful when chaining crypto operations that expect binary input.

Parameters:

Returns: A Buffer containing the raw HMAC bytes.

macBuf is hmacBuffer("data", key, "sha256")

hkdfBuffer(inputKey, salt, info, outputLength) — HKDF as Buffer

Like hkdf() but returns a raw Buffer instead of a hex string. Use HKDF to derive one or more strong keys from a shared secret or master key.

Parameters:

Returns: A Buffer containing the derived key bytes.

keyBuf is hkdfBuffer(sharedSecret, salt, "encryption", 32)

randomBytesBuffer(size) — Random Bytes as Buffer

Like randomBytes() but returns a raw Buffer instead of a hex string. Useful for IV generation and key material.

Parameters:

Returns: A Buffer of cryptographically secure random bytes.

iv is randomBytesBuffer(12)   -- 12-byte IV for AES-GCM

Practical example: chaining Buffer variants for encryption

The Buffer variants are designed to chain together. Here is a complete encrypt-then-send flow using randomBytesBuffer for the IV, hkdfBuffer to derive a key, and aesEncrypt to encrypt:

-- 1. Generate a random IV
iv is randomBytesBuffer(12)

-- 2. Derive an encryption key from a shared secret
salt is randomBytesBuffer(16)
encKey is hkdfBuffer(sharedSecret, salt, "encryption", 32)

-- 3. Encrypt the message
result is aesEncrypt(plaintext, encKey, iv, "aes-256-gcm")

-- 4. Compute a MAC over the ciphertext for additional integrity
macKey is hkdfBuffer(sharedSecret, salt, "signing", 32)
mac is hmacBuffer(result.ciphertext, macKey, "sha256")

-- 5. Send everything the receiver needs
say { salt: salt, iv: iv, ciphertext: result.ciphertext, tag: result.tag, mac: mac }
Buffer vs hex string variants: Use the Buffer variants (hmacBuffer, hkdfBuffer, randomBytesBuffer) when you are passing the output directly into another crypto function — this avoids unnecessary hex-encode/decode round trips. Use the hex string variants (hmac, hkdf, randomBytes) when you need a human-readable representation for logging, storing in a database text column, or sending in JSON.

constantTimeEqual(a, b) — Timing-Safe Comparison

Compares two buffers in constant time to prevent timing attacks. Always use this instead of is when comparing secrets, MACs, or hashes.

-- Verify a MAC safely
expected is hmac(message, key, "sha256")
if constantTimeEqual(expected, received):
  say "Message is authentic"

aesEncrypt(plaintext, key, iv, mode) — AES-256 Encryption

Encrypts data using AES-256. Supports "aes-256-gcm" (recommended, returns ciphertext + auth tag) and "aes-256-cbc" modes.

-- Encrypt with AES-256-GCM (authenticated encryption)
iv is randomBytes(12)
result is aesEncrypt(data, key, iv, "aes-256-gcm")
-- result has .ciphertext and .tag

aesDecrypt(ciphertext, key, iv, tag, mode) — AES-256 Decryption

Decrypts AES-256 encrypted data. For GCM mode, pass the authentication tag to verify integrity.

-- Decrypt AES-256-GCM
plaintext is aesDecrypt(result.ciphertext, key, iv, result.tag, "aes-256-gcm")

secureErase(buffer) — Zero Memory

Overwrites a buffer with zeros to remove sensitive data from memory. Use this for keys, passwords, and secrets when you are done with them.

-- Clean up sensitive key material
key is hkdf(shared, salt, "enc", 32)
-- ... use the key ...
secureErase(key)   -- key is now all zeros

secureRandomInt(min, max) — Cryptographic Random Integer

Generates a cryptographically secure random integer in the range [min, max). Unlike randomInt, this uses a CSPRNG suitable for security-sensitive code.

otp is secureRandomInt(100000, 999999)
say "Your code: " + otp

defineSchema(fields) / encode(schema, data) / decode(schema, buffer) — Binary Serialization

Compact binary serialization using tagged fields, similar to Protocol Buffers. Each field has a numeric tag and a type, so encoded messages are small, fast to parse, and forward-compatible.

Supported types

-- Define a message schema
MessageSchema is defineSchema({
  sender: { type: "string", tag: 1 },
  body: { type: "bytes", tag: 2 },
  timestamp: { type: "uint64", tag: 3 }
})

-- Encode to binary
wire is encode(MessageSchema, { sender: "alice", body: ciphertext, timestamp: now() })

-- Decode from binary
msg is decode(MessageSchema, wire)

Practical example: encoding a chat message for wire transfer

-- Define the schema for a chat message
ChatMessage is defineSchema({
  fromUser:   { type: "string",  tag: 1 },
  toUser:     { type: "string",  tag: 2 },
  content:    { type: "string",  tag: 3 },
  sentAt:     { type: "uint64",  tag: 4 },
  isEdited:   { type: "bool",    tag: 5 },
  attachment: { type: "bytes",   tag: 6 }
})

-- Encode and send over a WebSocket
outgoing is encode(ChatMessage, {
  fromUser: "alice",
  toUser: "bob",
  content: "Hey, check out this file!",
  sentAt: now(),
  isEdited: false,
  attachment: fileBytes
})
ws.send(outgoing)

-- On the receiving end, decode the binary back into an object
ws on "message" with data:
  msg is decode(ChatMessage, data)
  say msg.fromUser + ": " + msg.content
Binary serialization vs JSON: Use defineSchema / encode / decode when you need compact payloads (binary is typically 2-10x smaller than JSON), when you are sending data over WebSockets or WebRTC data channels, or when you need to embed raw binary (like encrypted ciphertext) without Base64 overhead. Use JSON when readability matters, when debugging network traffic, or when interoperating with APIs that expect JSON.

catch err: — Error Handling Shorthand

Quill provides two equivalent syntaxes for catching errors. Use whichever reads better in context.

Both syntaxes side by side

-- Syntax 1: try / if it fails
try:
  data is await fetchJSON("https://api.example.com/data")
  say data
if it fails err:
  say "Request failed: " + err

-- Syntax 2: try / catch (shorthand, same behavior)
try:
  data is await fetchJSON("https://api.example.com/data")
  say data
catch err:
  say "Request failed: " + err

Accessing error properties

The err variable is an object with useful properties you can inspect:

try:
  result is await fetch("https://api.example.com/users")
catch err:
  say "Message: " + err.message      -- Human-readable error text
  say "Name: " + err.name             -- Error type (e.g., "TypeError")
  say "Stack: " + err.stack           -- Full stack trace for debugging

Nested try/catch

You can nest try/catch blocks to handle errors at different levels:

try:
  user is await fetchJSON("https://api.example.com/user/1")

  try:
    await db.insert("users", user)
    say "Saved to database"
  catch dbErr:
    say "Database error: " + dbErr.message

catch networkErr:
  say "Network error: " + networkErr.message

Static Site Generator

Scaffold a static site project with a build pipeline, templating, and hot reload. Quill compiles .quill template files into plain HTML, copies your static assets, and produces a dist/ folder ready to deploy anywhere.

Scaffold a Project

quill site my-site

This creates the following directory structure:

my-site/
  pages/
    index.quill        -- Home page template
    about.quill         -- About page template
    _layout.quill       -- Shared layout (header, footer, nav)
  public/
    css/
      style.css         -- Your stylesheet
    images/             -- Image assets
    favicon.ico
  quill.toml            -- Project configuration (site title, base URL)
  build.quill           -- Build script that compiles pages and copies assets

Example page template

Pages are .quill files that use Quill's template syntax. Variables, loops, and components work inside templates:

-- pages/index.quill
layout is "_layout"
title is "Home"

content:
  <h1>Welcome to {{ site.title }}</h1>
  <p>This site was built with Quill.</p>

  <ul>
  for each post in posts:
    <li><a href="{{ post.url }}">{{ post.title }}</a></li>
  </ul>

Build & Serve

-- Build the site to dist/
quill run build.quill

-- Or serve with hot reload during development
quill serve

Build output

After running the build, your dist/ folder contains plain HTML and copied assets, ready to deploy:

dist/
  index.html            -- Compiled from pages/index.quill
  about.html            -- Compiled from pages/about.quill
  css/
    style.css           -- Copied from public/css/
  images/               -- Copied from public/images/
  favicon.ico           -- Copied from public/

Adding CSS and images

Place CSS files in public/css/ and images in public/images/. They are copied to dist/ during the build. Reference them with root-relative paths in your templates:

-- In your _layout.quill
<link rel="stylesheet" href="/css/style.css">

-- In any page template
<img src="/images/logo.png" alt="Logo">
Tip: Use Quill's string interpolation and components to build reusable layouts, headers, and footers across your pages. The _layout.quill file wraps every page automatically — put your <head>, navigation, and footer there once.