On this page:
3.1 Getting Started
3.1.1 Prerequisites
3.1.2 Step 1:   Set up the file
3.1.3 Step 2:   Configure the caller
3.1.4 Step 3:   Write the seed ashlar
3.1.5 Step 4:   Write the LLM ashlar
3.1.6 Step 5:   Compose and run
3.1.7 What you’ve built
3.1.8 Next steps
3.1.8.1 Switching providers
3.2 Your First Orchestration
3.2.1 Prerequisites
3.2.2 Step 1:   A schema for the structured output
3.2.3 Step 2:   A structured proposer ashlar
3.2.4 Step 3:   A completeness predicate
3.2.5 Step 4:   An ask-human ashlar
3.2.6 Step 5:   A stdin reader
3.2.7 Step 6:   Compose with ashlar-loop and ashlar-match
3.2.8 Step 7:   Run it
3.2.9 What you’ve built
3.2.10 Next steps
9.1

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:

  1. Try the proposer.

  2. Check if the proposal is complete.

  3. 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.