3 Tutorials
Learning-oriented guides that teach Stone by building a real pipeline step by step. Work through them in order on your first pass.
3.1 Getting Started
In this tutorial, we will build a Stone pipeline that takes a feature description and asks a language model to draft a one-paragraph summary. The finished pipeline 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 stone stone/llm-ashlar stone/llm-client)
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/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 functions that build the system prompt and user message from the DAG:
(define summarize (make-agent-ashlar caller #:produces 'summary #:queries '(requirement) #:max-turns 1 #:middleware '() #:system (lambda (dag) "You write concise one-paragraph summaries of feature descriptions.") #:user (lambda (dag) (node-get (dag-nearest-ancestor dag 'requirement) 'text "")) #:name 'summarize))
Three things to notice. #:queries '(requirement) declares that this ashlar reads 'requirement nodes; the validator uses that declaration to check, before any LLM call, that some upstream ashlar produces a 'requirement — if not, the pipeline 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.
#:user is a function from the DAG to a string. It uses dag-nearest-ancestor to pull the 'requirement node and node-get to read its 'text field. node-get’s default handles "no node upstream" and "no field" the same way — you don’t need to guard the read separately.
3.1.6 Step 5: Compose and run
Two ashlars become one pipeline with ~>:
(define pipeline (~> (make-seed-requirement "A Fibonacci class with memoization") summarize))
A pipeline 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-pipeline:
(define-values (result final-dag) (run-pipeline pipeline (make-dag))) (displayln (node-text result))
run-pipeline 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 pipeline 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 pipeline 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 pipeline 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 pipeline, 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 pipeline is agnostic to which caller is in use.
3.2 Your First Orchestration
In Getting Started we built a two-ashlar pipeline: 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 and feed the answer back in for the next attempt.
By the end, the pipeline 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. We also need stone/tools for channels; add it to the requires:
(require stone stone/llm-ashlar stone/llm-client stone/tools)
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 propose-config (make-agent-ashlar caller #:produces 'project-config #:queries '(requirement human-response) #:max-turns 1 #:middleware '() #:response-format project-config-schema #:system (lambda (dag) "You propose TDD project configurations based on a feature description. Return JSON with language, test_framework, impl_paths, and test_paths.") #:user (lambda (dag) (define base (node-get (dag-nearest-ancestor dag 'requirement) 'text "")) (define extra (node-get (dag-nearest-ancestor dag 'human-response) 'response "")) (if (non-empty-string? extra) (format "~a\n\nAdditional information from the user: ~a" base extra) base)) #: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 #:user function 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.
non-empty-string? lives in racket/string — add it to your requires:
(require stone stone/llm-ashlar stone/llm-client stone/tools racket/string)
Update the pipeline definition to use propose-config:
(define-values (pipeline channels) (call-with-collected-ask-human-channels (lambda () (~> (make-seed-requirement "A Fibonacci class with memoization") propose-config))))
We’ll fill in the call-with-collected-ask-human-channels wrapping over the next few steps; for now, just note that any ask-human channels built inside that thunk will be collected.
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 channel bundle (out, in, cancel) and a format function from the DAG to a question string. Inside call-with-collected-ask-human-channels, make-ask-human-channel auto-registers the channel with the collector.
(define ask-discover (make-ask-human (make-ask-human-channel 'ask-discover (make-channel) (make-channel) (make-channel)) #: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 with content (hasheq 'response answer-string).
3.2.6 Step 5: A stdin reader
make-ask-human puts questions on the out channel, but somebody has to prompt a person and put an answer on the in channel. Here, a background thread reading stdin. In a real application it might be a TUI, a Slack webhook, or a web dashboard — the channel indirection is why none of that leaks into the ashlars.
(define (start-stdin-reader! channels) (thread (lambda () (let loop () (define events (map (lambda (ch) (handle-evt (ask-human-channel-out ch) (lambda (question) (cons ch question)))) channels)) (define pair (apply sync events)) (define ch (car pair)) (define question (cdr pair)) (displayln (format "\n? ~a" question)) (display "> ") (flush-output) (define answer (read-line (current-input-port))) (channel-put (ask-human-channel-in ch) answer) (loop)))))
sync on a list of handle-evts blocks on all channels at once; whichever fires first wins. The reader displays the question, reads a line, and puts it on the matching in channel.
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 'response ""))) #: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 pipeline:
(define-values (pipeline channels) (call-with-collected-ask-human-channels (lambda () (~> (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 pipeline (a pattern the procedure extractor enables), see Route on a node from earlier in the pipeline.
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 puts a question on its out channel, the stdin reader prompts the human, 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
Start the stdin reader before running the pipeline, so it’s alive when the first ask fires:
(start-stdin-reader! channels) (define-values (result final-dag) (run-pipeline pipeline (make-dag))) (displayln "Final config:") (displayln (node-content result))
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 pipeline 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 communicates through channels.
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 pipeline 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 pipeline did, follow Trace a run in the how-to section.