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 supervisor
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.0.2

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:

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