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 call-llm
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 runner
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.3

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-call-llm 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 call-llm factories.

3.1.3 Step 2: Configure the call-llm🔗ℹ

An ashlar that calls a language model does so through a call-llm — a function that knows how to talk to a specific API. For an OpenAI-compatible server we use make-openai-llm:

(define call-llm (make-openai-llm #:url "http://localhost:8000"
                                  #:model "your-model-name"))
(default-call-llm call-llm)

Replace "your-model-name" with whatever your server serves — for example "Qwen/Qwen3.5-35B-A3B" or "llama3.1:8b". The model is fixed on the call-llm at construction via #:model. default-call-llm is the ambient caller every LLM ashlar falls back to when no #:call-llm is 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 leaves, 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 call-llm 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 #:call-llm call-llm
    #: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 is one data structure, no read-set to declare separately. The ashlar’s read-set is derived from the context’s lenses, and the validator 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 by applying it directly to a fresh DAG — an ashlar is a plain procedure from DAG to DAG:

(define final-dag (ashlar (make-dag)))
 
(displayln (node-text (dag-cursor final-dag)))

Applying ashlar to (make-dag) runs it and returns the updated DAG carrying every node the pass produced. dag-cursor pulls out the latest node — here, the summary. node-text pulls the text out of that node — it handles both bare-string content and (hasheq 'text ...) content uniformly.

This direct-application form is a pure, session-less pass: nothing is journaled, nothing is observed. To run the same ashlar durably — journaled to disk, resumable across crashes, with a default logger narrating progress — use run-ashlar with a #:record instead; Your First Orchestration introduces that next.

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 call-llm in Step 2 for an Anthropic one:

(define call-llm
  (make-anthropic-llm #:api-key (getenv "ANTHROPIC_API_KEY")
                      #:model "claude-sonnet-4-6-20250514"))
(default-call-llm call-llm)

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 call-llm 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, answered 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-call-llm 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 #:call-llm call-llm
    #: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 suspends the run — no thread blocks; run-ashlar returns, and the far side answers and re-invokes it to resume.

(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. Reaching a fresh question doesn’t produce a node at all — it appends a request node and suspends the run, so run-ashlar returns instead of blocking. We’ll see that shape in Step 7.

3.2.6 Step 5: A runner🔗ℹ

make-ask-human poses questions on the run’s threshold, but somebody has to prompt a person, capture the answer, and hand it back. That somebody is the runner — whatever owns the loop on the far side of the threshold. Because a request suspends rather than blocking a thread, the simplest runner is a plain loop: run, check whether the ashlar is waiting on a request, answer it if so, and run again.

(define (run-to-completion ashlar r)
  (define d (run-ashlar ashlar #:record r))
  (cond
    [(dag-awaiting-request? d)
     (define req (dag-cursor d))
     (define question (node-get* req 'payload 'question))
     (displayln (format "\n? ~a" question))
     (display "> ")
     (flush-output)
     (define answer (read-line (current-input-port)))
     (cond
       [(eof-object? answer) (halt-request! r req 'user-quit)]
       [else (answer-request! r req answer)])
     (run-to-completion ashlar r)]
    [else d]))

dag-awaiting-request? is true exactly when run-ashlar returned a DAG cursored on a request node instead of finishing. node-get* walks the request node’s 'payload hash to read the question. On end-of-file we abandon the session with halt-request! (a 'user-quit halt) instead of answering; otherwise answer-request! appends the answer as the request’s child and the next run-ashlar call finds it by topology. In a real application the runner might be the TUI, a Slack webhook, or a web dashboard — the threshold indirection (and the fact that answering is just appending a node) is why none of that leaks into the ashlars.

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 '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 runner 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🔗ℹ

run-ashlar always runs durably under a record; left to itself it creates its own threshold and attaches a default logger. Ask-human needs a journal to suspend and resume against — a request with nowhere to record its answer is a degenerate, unsupported case — so here we open a record on a real path and run it with our run-to-completion loop from Step 5:

(define r (open-record "orchestration.jsonl" #:threshold (make-threshold)))
 
(define final-dag (run-to-completion ashlar r))
 
(displayln "Final config:")
(displayln (node-content (dag-cursor final-dag)))

run-ashlar #:record binds the record’s threshold as (current-threshold) for each call, so ask-discover solicits through it; our loop notices the suspend (dag-awaiting-request?), answers, and calls run-ashlar again to resume. 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.