3 Tutorials
Learning-oriented guides that teach Stone by building a real ashlar step by step. Work through them in order on your first pass.
3.1 Getting Started
In this tutorial, we will build a Stone ashlar that takes a feature description and asks a language model to draft a one-paragraph summary. The finished ashlar is two ashlars: a deterministic ashlar that seeds the description into the DAG, and an LLM ashlar that reads it and produces the summary. We’ll sequence them with ~> and run the whole thing end-to-end.
If you are new to Racket, skim Reading Racket first — you don’t need to know the language deeply, just enough to parse function definitions and hash literals.
3.1.1 Prerequisites
Racket 9.x installed and on your PATH.
- The Stone package, installed from the catalog or a local checkout. If you cloned the repo:
raco pkg install --link --batch --auto --name ashlar .
An OpenAI-compatible LLM server running locally — vLLM or ollama both work with no API key. The tutorial assumes one at http://localhost:8000. If that isn’t practical for you, swap to Anthropic at the end of the tutorial; it’s a one-line change.
Make a working directory for this tutorial and cd into it. Everything we write lives in one file, tutorial.rkt.
3.1.2 Step 1: Set up the file
Create tutorial.rkt with the language declaration and the three Stone modules we’ll use:
"tutorial.rkt"
#lang racket (require (except-in stone context context?) stone/llm-ashlar stone/llm-client stone/context-struct)
stone re-exports the composition primitives (~>, make-ashlar), the DAG operations (typed-node, dag-nearest-ancestor, node-get, make-dag), and the default-model parameter. stone/context-struct provides the context constructors an agent ashlar’s #:context is built from (system, user, history) — we except-in stone’s unrelated runtime context struct so those win. stone/llm-ashlar adds make-agent-ashlar. stone/llm-client adds the caller factories.
3.1.3 Step 2: Configure the caller
An ashlar that calls a language model does so through a caller — a function that knows how to talk to a specific API. For an OpenAI-compatible server we use make-openai-caller:
(define caller (make-openai-caller #:url "http://localhost:8000")) (default-model "your-model-name")
Replace "your-model-name" with whatever your server serves — for example "Qwen/Qwen3.5-35B-A3B" or "llama3.1:8b". default-model is a parameter every LLM ashlar reads when #:model isn’t given; one call at the top and every LLM ashlar picks it up.
3.1.4 Step 3: Write the seed ashlar
The first ashlar is deterministic. It doesn’t call the LLM; it puts a typed node into the DAG carrying the feature description we want summarized.
(define (make-seed-requirement text) (make-ashlar (lambda (dag) (typed-node dag 'requirement (hasheq 'text text))) #:produces 'requirement #:name 'seed-requirement))
make-seed-requirement is a factory: call it with a string and get back an ashlar that, when run, places a 'requirement node on the DAG. typed-node defaults the node’s parents to the DAG’s current heads, so the common case stays terse.
#:produces is the contract: it tells downstream ashlars (and the validator) that this ashlar writes a 'requirement-typed node. #:name is a label for logs.
3.1.5 Step 4: Write the LLM ashlar
The second ashlar is an LLM ashlar. We build it with make-agent-ashlar — a caller plus a #:context that describes how the outgoing messages are projected from the DAG. The context constructors (system, user, history) come from stone/context-struct, already in the requires from Step 1:
(define summarize (make-agent-ashlar caller #:produces 'summary #:max-turns 1 #:middleware '() #:context (context (system "You write concise one-paragraph summaries of feature descriptions.") (user 'requirement)) #:name 'summarize))
Three things to notice. #:context subsumes the former #:system, #:user, and #:queries keywords — one data structure, no read-set to declare separately. The ashlar’s read-set is derived from the context’s lenses, and the validator still uses it to check, before any LLM call, that some upstream ashlar produces a 'requirement — if not, the ashlar is refused.
#:max-turns 1 with an empty #:middleware list says "one LLM turn, no tools, take the reply as the answer." That’s the single-shot pattern.
(system str) sets a literal system prompt. (user 'requirement) is sugar for "the latest 'requirement node, text-extracted" — it reaches into the DAG with the same nearest-ancestor semantics you’d write by hand, and handles "no node upstream" cleanly.
3.1.6 Step 5: Compose and run
Two ashlars become one ashlar with ~>:
(define ashlar (~> (make-seed-requirement "A Fibonacci class with memoization") summarize))
An ashlar is itself an ashlar — it has the same shape as its components, and you can hand it to anything that takes an ashlar, nest it inside a loop, or compose it further. One layer, all the way down.
Run it with run-ashlar:
(define-values (result final-dag) (run-ashlar ashlar (make-dag))) (displayln (node-text result))
run-ashlar takes an ashlar and a DAG, calls the ashlar, and returns two values: the final node and the updated DAG. define-values binds both. node-text pulls the text out of the result node — it handles both bare-string content and (hasheq 'text ...) content uniformly.
Now run the file:
$ racket tutorial.rkt |
You should see something like:
This feature introduces a Fibonacci class that caches previously |
computed values so repeated calls return instantly, trading a small |
amount of memory for dramatic speedups on recursive calls. |
Exact wording depends on your model. What matters is that the ashlar pulled the requirement out of the DAG, asked the LLM for a summary, and placed the summary back into the DAG as a typed node. The full history of the run is sitting in final-dag — two nodes, the requirement and the summary, one pointing at the other.
3.1.7 What you’ve built
You have a two-ashlar ashlar that seeds a typed requirement into a shared DAG, calls an LLM to summarize it, and writes the summary back as a typed node. Every piece of it — the deterministic seed, the LLM call, the composition — is expressed as an ashlar or a composition primitive over ashlars.
3.1.8 Next steps
To see the ashlar emit a structured trace you can inspect after the fact, follow Trace a run in the how-to section.
To add a loop, a conditional branch, and a human-in-the-loop to this same ashlar, continue with Your First Orchestration.
To understand why ashlars are shaped this way — the atomic unit, the shared DAG, failure as a value — read Ashlars.
To see the full composition vocabulary — ashlar-match, ashlar-map, ashlar-parallel, ashlar-reduce — read Edge Primitives.
3.1.8.1 Switching providers
If a local OpenAI-compatible server isn’t practical, swap the caller in Step 2 for an Anthropic one:
(define caller (make-anthropic-caller #:api-key (getenv "ANTHROPIC_API_KEY"))) (default-model "claude-sonnet-4-6-20250514")
Set ANTHROPIC_API_KEY in your environment before running. The rest of the tutorial is unchanged — every other ashlar in the ashlar is agnostic to which caller is in use.
3.2 Your First Orchestration
In Getting Started we built a two-ashlar ashlar: a deterministic seed put a requirement into the DAG, and an LLM ashlar read it and produced a one-paragraph summary. That was a straight line.
In this tutorial we’ll turn the straight line into an orchestration. The LLM ashlar will be replaced with one that proposes a structured project configuration, and we’ll wrap it in a ashlar-loop that keeps trying until the proposal has every field we need. When the LLM’s output is incomplete, the loop will ask a human for clarification — through the run’s threshold — and feed the answer back in for the next attempt.
By the end, the ashlar can loop, recover from incomplete output, and pause for a person — all expressed as ashlars, composed with the same primitives we already know.
3.2.1 Prerequisites
You completed Getting Started and have a working tutorial.rkt.
The same LLM server or Anthropic key from before.
Copy tutorial.rkt to orchestration.rkt so we can edit freely:
$ cp tutorial.rkt orchestration.rkt |
Every change below is made in orchestration.rkt. This tutorial adds three modules: stone/context-struct for the agent’s #:context, and stone/threshold + stone/record for the durable, supervised run that ask-human needs. We except-in stone’s unrelated runtime context struct so the context DSL wins:
(require (except-in stone context context?) stone/llm-ashlar stone/llm-client stone/context-struct stone/threshold stone/record)
3.2.2 Step 1: A schema for the structured output
Instead of a paragraph of prose, we want a machine-readable project configuration: language, test framework, implementation directories, test directories. make-json-schema builds the hash that make-agent-ashlar understands as a structured-output request.
Add this below the default-model call:
(define project-config-schema (make-json-schema "project_config" (hasheq 'language (hasheq 'type "string") 'test_framework (hasheq 'type "string") 'impl_paths (hasheq 'type "array" 'items (hasheq 'type "string")) 'test_paths (hasheq 'type "array" 'items (hasheq 'type "string"))) '("language" "test_framework" "impl_paths" "test_paths")))
Three arguments: a name, a hash of property descriptions, and a list of required field names.
3.2.3 Step 2: A structured proposer ashlar
Replace the summarize ashlar with a structured proposer. It queries the same 'requirement node but produces a 'project-config node whose content is a parsed JSON hash.
(define (build-user-message dag) (define base (node-get (dag-nearest-ancestor dag 'requirement) 'text "")) (define extra (node-get (dag-nearest-ancestor dag 'human-response) 'text "")) (if (non-empty-string? extra) (format "~a\n\nAdditional information from the user: ~a" base extra) base)) (define propose-config (make-agent-ashlar caller #:produces 'project-config #:max-turns 1 #:middleware '() #:response-format project-config-schema #:context (context (system "You propose TDD project configurations based on a feature description. Return JSON with language, test_framework, impl_paths, and test_paths.") (user (raw-lens build-user-message #:queries '(requirement human-response)))) #:name 'propose-config))
Three things are new. #:response-format project-config-schema tells the agent to parse the response as JSON conforming to our schema; on a parse failure, the agent produces a 'llm-parse-failed failure node. #:produces 'project-config declares the output type. And the #:context’s user element is a raw-lens that reads both the requirement and any prior human response — on the first iteration the human response is absent and node-get returns "", so the prompt is just the requirement; on later iterations the human’s answer is appended. The lens’s #:queries is the read-set the validator checks; there is no separate #:queries keyword on the ashlar anymore — the context is the declaration.
non-empty-string? lives in racket/string — add it to your requires:
(require (except-in stone context context?) stone/llm-ashlar stone/llm-client stone/context-struct stone/threshold stone/record racket/string)
Update the ashlar definition to use propose-config. No channel plumbing is needed — an ask-human ashlar solicits through the run’s threshold, which we set up in Step 7:
(define ashlar (~> (make-seed-requirement "A Fibonacci class with memoization") propose-config))
We’ll grow this into the loop over the next few steps.
3.2.4 Step 3: A completeness predicate
The LLM won’t always produce every field. We need a predicate that says yes when a proposal is complete and no when something is missing. We’ll write the check at the node level — (node -> boolean) — and wrap it for the loop in a moment.
(define (has-project-config? node) (and (equal? (node-type node) 'project-config) (hash? (node-content node)) (hash-has-key? (node-content node) 'language) (hash-has-key? (node-content node) 'test_framework) (hash-has-key? (node-content node) 'impl_paths) (hash-has-key? (node-content node) 'test_paths)))
3.2.5 Step 4: An ask-human ashlar
Human interaction is an ashlar. make-ask-human builds one. It takes a format function from the DAG to a question string plus the usual metadata — no channel. At run time it poses the question through the run’s threshold and blocks until a supervisor answers.
(define ask-discover (make-ask-human #:format-fn (lambda (dag) "The proposed project config is missing required fields. Please describe the project: what language, test framework, and directory layout should I use?") #:name 'ask-discover #:produces 'human-response #:queries '()))
The produced node has type 'human-response; the answer is stored under both 'human-response and 'text, so (node-get node 'text) and our build-user-message lens read it cleanly. A cancelled ask produces a halt node instead.
3.2.6 Step 5: A supervisor
make-ask-human poses questions on the run’s threshold, but somebody has to prompt a person and answer them. That somebody is a supervisor: a thread that threshold-subscribes to the run’s crossing feed, watches for an 'ask crossing, and resolves it with threshold-answer!. In a real application it might be the TUI, a Slack webhook, or a web dashboard — the threshold indirection is why none of that leaks into the ashlars.
(define (start-stdin-supervisor! th sub) (thread (lambda () (let loop () (define c (sync sub)) (when (eq? (crossing-kind c) 'ask) (define p (crossing-payload c)) (define id (hash-ref p 'id)) (define question (hash-ref p 'question)) (displayln (format "\n? ~a" question)) (display "> ") (flush-output) (define answer (read-line (current-input-port))) (cond [(eof-object? answer) (threshold-cancel! th id 'user-quit)] [else (threshold-answer! th id answer)])) (loop)))))
(sync sub) blocks on the subscription until a crossing arrives. The supervisor filters for 'ask crossings — the same feed also carries the agent’s token/turn observations — displays the question, reads a line, and answers by the ask’s 'id. On end-of-file it cancels the session with 'user-quit.
3.2.7 Step 6: Compose with ashlar-loop and ashlar-match
The loop’s body needs to do three things:
Try the proposer.
Check if the proposal is complete.
If incomplete, ask the human; otherwise, no-op.
Two small ashlars support that shape:
(define noop (make-ashlar (lambda (dag) (typed-node dag 'human-response (hasheq 'human-response "" 'text ""))) #:produces 'human-response #:name 'noop-response)) (define check-completeness (make-ashlar (lambda (dag) (define latest (dag-nearest-ancestor dag 'project-config)) (typed-node dag 'completeness (hasheq 'complete? (and latest (has-project-config? latest))))) #:produces 'completeness #:queries '(project-config) #:name 'check-completeness))
Now assemble the loop and complete the ashlar — a plain ~>, no channel plumbing:
(define ashlar (~> (make-seed-requirement "A Fibonacci class with memoization") (ashlar-loop (~> propose-config check-completeness (ashlar-match (lens 'complete?) [#t noop] [#f ask-discover])) #:until (on-latest has-project-config?) #:max 5)))
We wrap with on-latest because has-project-config? only inspects the most recent proposal.
To understand why #:until receives the DAG and not just the latest node, see Edge Primitives. For routing on a node from earlier in the ashlar (a pattern the procedure extractor enables), see Route on a node from earlier in the ashlar.
Here is what happens at run time. propose-config writes a 'project-config node. check-completeness inspects it and writes a 'completeness node carrying a 'complete? flag. The ashlar-match reads that flag: on #t it runs noop (writes a dummy human-response so the types line up); on #f it runs ask-discover, which poses a question on the run’s threshold, the supervisor prompts the human, and the answer comes back as a 'human-response node.
The loop’s #:until (on-latest has-project-config?) predicate looks at the most recent node the body produced. If the match ran ask-discover, the tail node is a 'human-response and the loop keeps going. The next iteration’s propose-config sees both the prior attempt and the human’s answer, because inside a ashlar-loop, iteration N+1 sees everything iteration N produced on the DAG.
3.2.8 Step 7: Run it
Because ask-human needs a supervisor to answer it, we run under a threshold and a record — not a bare (run-ashlar ashlar (make-dag)). Build the threshold, attach it so decisions block for us, subscribe, open the record (a durable journal on disk), start the supervisor, and run:
(define th (make-threshold)) (threshold-attach! th) (define sub (threshold-subscribe th)) (define r (open-record "orchestration.jsonl" #:threshold th)) (start-stdin-supervisor! th sub) (define final-dag (run-ashlar ashlar #:record r)) (displayln "Final config:") (displayln (node-content (dag-latest-head final-dag)))
run-ashlar #:record binds th as (current-threshold) for the run, so ask-discover solicits through it and our supervisor answers. The run journals to orchestration.jsonl; if it crashes, re-running resumes from the journal (see Run a durable, resumable session).
Run:
$ racket orchestration.rkt |
If the LLM nails the schema on the first try, the loop exits after one iteration and you see the final config. If not, a question appears on your terminal with a > prompt — type a sentence ("Use Python with pytest under src/ and tests/") and press enter. The loop picks up the answer, calls the LLM again with the human context appended, and either exits with a valid config or asks you another question, up to five attempts before it gives up with a 'loop-exhausted failure.
3.2.9 What you’ve built
You turned a straight-line ashlar into an orchestration. The difference is three primitives: ashlar-loop gave you bounded repetition with a predicate, ashlar-match inside the loop body let ask-discover fire only when the proposal was incomplete, and make-ask-human lifted human interaction to the topology level by expressing it as an ashlar that solicits through the run’s threshold.
None of the new pieces are a different kind of thing from the ashlars we started with. The match is an ashlar. The ask-human is a ashlar. The loop is an ashlar. The whole ashlar is still one ashlar you could drop into another sequence tomorrow.
3.2.10 Next steps
For the full composition vocabulary — ashlar-map, ashlar-parallel, ashlar-reduce — read Edge Primitives.
For conversation-level healing inside an LLM ashlar — when the model’s own draft is what needs to improve — see the adversary/heal-with pair in Agents and Tools.
For why human interaction is expressed as an ashlar constructor instead of a side door, see Ask Human.
For a trace of what the ashlar did, follow Trace a run in the how-to section.