On this page:
4.1 Debug an ashlar
4.1.1 Prerequisites
4.1.2 1. Enable a receiver
4.1.3 2. Parameterize a trace id
4.1.4 3. Read the stream
4.1.5 4. Enable debug-level events when you need them
4.1.6 5. Follow a specific trace-id
4.1.7 6. Read failure events
4.1.8 7. Check the DAG after a run
4.1.9 8. Test-time interception
4.1.10 Troubleshooting
4.1.11 See also
4.2 Trace a run
4.2.1 Prerequisites
4.2.2 Steps
4.2.2.1 1. Require the logging pieces
4.2.2.2 2. Open a trace file and a log receiver
4.2.2.3 3. Start a background thread to write events
4.2.2.4 4. Set trace-id and span-id around the run
4.2.2.5 5. Run and inspect
4.2.3 Reading the trace
4.2.4 Troubleshooting
4.2.5 See also
4.3 Validate a topology
4.3.1 Prerequisites
4.3.2 1. Validate from the REPL or a script
4.3.3 2. Validate from the command line
4.3.4 3. Interpret common errors
4.3.4.1 'missing-producer
4.3.4.2 'maybe-unavailable
4.3.4.3 'invalid-lens
4.3.4.4 'fanout-not-reduced
4.3.5 4. Integrate validation into your workflow
4.3.5.1 As a pre-run check
4.3.5.2 As a CI gate
4.3.5.3 In tests
4.3.6 5. Enumerate ashlars in a topology
4.3.6.1 Orphan-ashlar guard
4.3.7 Troubleshooting
4.3.8 See also
4.4 Write a custom ashlar
4.4.1 Prerequisites
4.4.2 1. The shape of a custom ashlar
4.4.3 2. Reading from the DAG
4.4.4 3. Producing a typed node
4.4.5 4. Handling failure
4.4.6 5. Side effects are fine
4.4.7 6. Choosing the right constructor
4.4.8 7. Declaring metadata correctly
4.4.9 Troubleshooting
4.4.10 See also
4.5 Route on a node from earlier in the ashlar
4.5.1 Prerequisites
4.5.2 1. Use a procedure extractor, not a lens
4.5.3 2. Walk to the classifier’s node
4.5.4 3. Project the routing key with node-get
4.5.5 Troubleshooting
4.5.6 See also
4.6 Add structured output to an LLM ashlar
4.6.1 Prerequisites
4.6.2 1. Define a schema
4.6.3 2. Set #:  response-format on the ashlar
4.6.4 3. Read the structured output downstream
4.6.5 4. Handle parse failures
4.6.6 5. Fold a human step into the body when retries aren’t enough
4.6.7 6. Auto-derived schema metadata
4.6.8 7. Dispatching to different node shapes with #:  finalize
4.6.9 Troubleshooting
4.6.10 See also
4.7 Add human interaction to an ashlar
4.7.1 Prerequisites
4.7.2 1. Add an ask-human ashlar
4.7.3 2. Run under a record and wire a supervisor
4.7.4 3. Use an approval loop pattern
4.7.5 4. Use a learning loop pattern
4.7.6 5. Branch on a human response
4.7.7 6. Decline a pending question
4.7.8 7. Test with a scripted supervisor
4.7.9 Troubleshooting
4.7.10 See also
4.8 Run a durable, resumable session
4.8.1 Record, journal, context — three distinct things
4.8.2 1. Open a record
4.8.3 2. Run durably
4.8.4 3. Resume after a crash
4.8.5 4. Session loop:   run until a terminal halt
4.8.6 5. Sessions on the command line
4.8.7 See also
4.9 Handle failures in an ashlar
4.9.1 Prerequisites
4.9.2 1. Return a failure node from your ashlar
4.9.3 2. Understand what propagates when
4.9.4 3. Check for failure at the top level
4.9.5 4. Retry with ashlar-loop
4.9.6 5. Recover by folding repair into the loop body
4.9.7 6. Retry on body failure, not just predicate miss
4.9.8 7. Read failure events from the trace
4.9.9 8. Fail fast vs recover
4.9.10 Troubleshooting
4.9.11 See also
4.10 Configure a caller
4.10.1 Prerequisites
4.10.2 1. Open  AI-compatible servers
4.10.3 2. Anthropic
4.10.4 3. Disable Qwen3.5 thinking mode
4.10.5 4. Provider-specific reasoning controls
4.10.6 5. Reserved keys
4.10.7 6. Multiple callers in one ashlar
4.10.8 Troubleshooting
4.10.9 See also
4.11 Use the TUI
4.11.1 Prerequisites
4.11.2 1. Launch the TUI
4.11.3 2. Panel layout
4.11.4 3. Keybindings
4.11.4.1 Global
4.11.4.2 Normal mode
4.11.4.3 Input mode
4.11.4.4 Modal mode
4.11.5 4. Answer an ask-human prompt
4.11.6 5. Cancel, decline, and quit
4.11.7 6. Resume a session
4.11.8 Troubleshooting
4.11.9 See also
4.12 Test ashlars that use tools
4.12.1 Prerequisites
4.12.2 1. Live test:   does this ashlar actually call ask_  user?
4.12.3 2. Mock test:   ashlar wiring without network calls
4.12.4 3. Catch accidentally-unstubbed tools with #:  strict-tools?
4.12.5 4. Bound test runtime with #:  timeout
4.12.6 5. Reference:   which assertion should I use?
4.12.7 6. Stub helpers
4.12.8 7. Response builders
4.12.9 8. When to use live vs. mock
4.12.10 Troubleshooting
4.12.11 See also
4.13 Use the ashlar skills
4.13.1 Prerequisites
4.13.2 1. Install the skills
4.13.3 2. The two-skill flow
4.13.4 3. What scope-ashlar writes
4.13.5 4. What build-ashlar generates
4.13.6 5. When NOT to use these skills
4.13.7 See also
4.14 Coming from Lang  Chain /   Llama  Index /   bare SDK
4.14.1 If you’re coming from Lang  Chain /   Lang  Graph
4.14.1.1 State
4.14.1.2 Edges and routing
4.14.1.3 Chains
4.14.1.4 Agents
4.14.1.5 Streaming
4.14.2 If you’re coming from Llama  Index
4.14.3 If you’re coming from a bare SDK
4.14.3.1 Mapping SDK calls to Stone
4.14.4 Racket-specific things Lang  Chain doesn’t have
4.14.5 Why you’d choose Stone
4.14.6 See also
9.3.0.2

4 How-to guides🔗ℹ

Task-oriented guides for competent users with a specific problem to solve. Each guide is self-contained; read only the one you need.

4.1 Debug an ashlar🔗ℹ

This guide shows how to inspect a running ashlar’s events, filter them by ashlar name or event type, and correlate work across ashlars using trace and span ids. For the conceptual model see Observability.

4.1.1 Prerequisites🔗ℹ

  • An ashlar that runs. If you don’t have one, work through Getting Started first.

  • A jq install (or equivalent) for filtering JSON on the command line.

4.1.2 1. Enable a receiver🔗ℹ

Stone’s observability is a single logger, stone-logger. To see anything, subscribe a receiver and drain it from a thread. The pattern below writes each event to trace.jsonl as JSON:

(require racket/logging stone/logging json)
 
(define trace-out (open-output-file "trace.jsonl" #:exists 'append))
(define receiver  (make-log-receiver stone-logger 'info))
 
; stone-event builds hashes with symbol keys and arbitrary
; Racket values; write-json only accepts a narrow subset,
; so coerce everything on the way out.
(define (->json-safe v)
  (cond
    [(or (string? v) (boolean? v) (exact-integer? v)) v]
    [(and (real? v) (rational? v)) (exact->inexact v)]
    [(symbol? v) (symbol->string v)]
    [(keyword? v) (keyword->string v)]
    [(path? v) (path->string v)]
    [(null? v) '()]
    [(hash? v)
     (for/hash ([(k val) (in-hash v)])
       (values (if (symbol? k) k (string->symbol (format "~a" k)))
               (->json-safe val)))]
    [(list? v) (map ->json-safe v)]
    [(vector? v) (map ->json-safe (vector->list v))]
    [(void? v) 'null]
    [else (format "~v" v)]))
 
(define logger-thread
  (thread
    (lambda ()
      (let loop ()
        (define evt (sync receiver))
        (with-handlers ([exn:fail? (lambda (_) (void))])
          (write-json
            (hash 'level   (->json-safe (vector-ref evt 0))
                  'message (->json-safe (vector-ref evt 1))
                  'data    (->json-safe (vector-ref evt 2))
                  'topic   (->json-safe (vector-ref evt 3)))
            trace-out)
          (newline trace-out)
          (flush-output trace-out))
        (loop)))))

The ->json-safe helper isn’t optional. stone-event emits Racket hashes keyed by symbols and holding arbitrary values (DAG nodes, message lists, paths); write-json will raise on anything outside its accepted subset.

4.1.3 2. Parameterize a trace id🔗ℹ

Wrap run-ashlar in a parameterize so every event carries the same trace id:

(define tid (generate-id "run-"))
(define sid (generate-id "span-"))
 
(parameterize ([current-trace-id tid]
               [current-span-id sid])
  (run-ashlar my-ashlar (make-dag)))

Every event in this run will carry the same 'trace-id, making it easy to isolate a single run in a shared log file. Span ids are re-allocated per ashlar invocation by the framework wrapper.

4.1.4 3. Read the stream🔗ℹ

With the trace file written, common debugging questions become jq queries. Each line is one event; the structured payload is under 'data.

All events:

jq '.' trace.jsonl

Just API calls:

jq 'select(.data.event == "api-call")' trace.jsonl

Just errors:

jq 'select(.level == "error")' trace.jsonl

Events for a specific ashlar:

jq 'select(.data."ashlar-name" == "discover-project")' trace.jsonl

Group by ashlar name and count:

jq -s 'group_by(.data."ashlar-name")

| map({ashlar: .[0].data."ashlar-name", count: length})' trace.jsonl

ashlar-name is hyphenated, so it must be quoted in jq paths. Same goes for trace-id, span-id, parent-span-id.

4.1.5 4. Enable debug-level events when you need them🔗ℹ

'ashlar-start, 'ashlar-end, and 'middleware-run are emitted at debug level. A receiver subscribed at 'info won’t see them. When you need "did this ashlar actually run?", subscribe at debug:

(define receiver (make-log-receiver stone-logger 'debug))

Debug is noisy. Use it, get the answer, turn it back down.

4.1.6 5. Follow a specific trace-id🔗ℹ

When you’re tailing a shared log file and one run misbehaved, filter by trace id:

jq 'select(.data."trace-id" == "run-1775875329701-93248")' trace.jsonl

generate-id returns "<prefix><ms>-<random>", so the prefix you chose ("run-", "tdd-", whatever) is the fastest way to scope a query.

4.1.7 6. Read failure events🔗ℹ

When any ashlar creates a failure node, stone/logging auto-emits an 'error-level event. No user code needs to opt in:

jq 'select(.level == "error")' trace.jsonl

The 'event field of an error event is the failure kind (e.g. 'llm-parse-failed, 'loop-exhausted). The 'reason field is the message. For a given failure, the triple (span-id, event, reason) usually tells you which ashlar failed and why in one line.

4.1.8 7. Check the DAG after a run🔗ℹ

The ashlar’s final DAG is still available after run-ashlar returns. When logs say "something failed" but you want to see what actually landed in the DAG, inspect it directly:

(define-values (result final-dag)
  (parameterize ([current-trace-id tid] [current-span-id sid])
    (run-ashlar my-ashlar (make-dag))))
 
(for ([(id n) (in-hash (dag-nodes final-dag))])
  (displayln (list (node-type n) (node-content n))))

Useful when an ashlar produced a partial result before failing downstream — the partial result is still in final-dag, and no amount of log-scanning will show you its content the way dag-nodes will.

4.1.9 8. Test-time interception🔗ℹ

For unit tests, use with-intercepted-logging to capture events in-process without writing to a file:

(require racket/logging stone/logging)
 
(define events (box '()))
(with-intercepted-logging
  (lambda (v) (set-box! events (cons v (unbox events))))
  (lambda () (run-ashlar my-ashlar (make-dag)))
  #:logger stone-logger
  'info 'stone)

The #:logger stone-logger keyword is mandatory. Because stone-logger is defined with #:parent #f, its events never reach the root logger, and a call that omits the keyword attaches to the root logger — it captures nothing and the test passes for the wrong reason.

4.1.10 Troubleshooting🔗ℹ

  • Trace file is empty. Either no receiver is subscribed, or the receiver thread hasn’t drained before process exit. Kill or join the logger thread before closing the output port.

  • Events have ashlar-name: false. The ashlar was created without #:name or #:produces. The framework should auto-generate a name; if you see false the framework may be out of date relative to the ashlar.

  • with-intercepted-logging captures nothing. You forgot #:logger stone-logger.

  • Duplicate events from the same ashlar. A ashlar-meta can be composed into multiple positions in a ashlar, and each invocation emits its own events with distinct span-ids — the events aren’t duplicates, they’re separate runs of the same ashlar.

4.1.11 See also🔗ℹ

  • Observability — the conceptual model.

  • Trace a run — the minimal setup for writing a trace file.

  • Handle failures (below) — writing ashlars that recover from the error events you see here.

4.2 Trace a run🔗ℹ

Stone emits a stream of structured events on a dedicated logger, stone-logger, every time an ashlar runs. This guide shows you how to capture that stream to a JSONL file so you can inspect exactly what happened — which ashlars ran in what order, what each LLM call sent and received, where failures fired.

For the conceptual picture of Stone’s observability, see Observability.

4.2.1 Prerequisites🔗ℹ

  • A working Stone ashlar — if you don’t have one yet, follow Getting Started first.

4.2.2 Steps🔗ℹ

4.2.2.1 1. Require the logging pieces🔗ℹ

Add two requires:

(require stone/logging
         racket/logging)

stone/logging re-exports stone-logger and the correlation parameters. racket/logging provides make-log-receiver.

4.2.2.2 2. Open a trace file and a log receiver🔗ℹ

Before running the ashlar, open a file to write events to and subscribe a receiver to stone-logger at the level you want:

(define trace-out (open-output-file "trace.jsonl" #:exists 'replace))
(define receiver  (make-log-receiver stone-logger 'info))

'info sees 'api-call, 'api-response, 'agent-ashlar-start, 'agent-ashlar-end, 'tool-dispatch, and all failure events. Use 'debug if you also want per-ashlar 'ashlar-start and 'ashlar-end events — noisy but useful when debugging composition.

4.2.2.3 3. Start a background thread to write events🔗ℹ
(define logger-thread
  (thread
    (lambda ()
      (let loop ()
        (define evt (sync receiver))
        (write (hasheq 'level (vector-ref evt 0)
                       'data  (vector-ref evt 2))
               trace-out)
        (newline trace-out)
        (flush-output trace-out)
        (loop)))))

The receiver delivers events as Racket log vectors #(level message data topic). Index 2 is the structured data hash that stone-event built — that’s what you want to write. The thread loops forever, syncing on the receiver and writing one line per event.

4.2.2.4 4. Set trace-id and span-id around the run🔗ℹ
(define-values (result final-dag)
  (parameterize ([current-trace-id (generate-id "run-")]
                 [current-span-id  (generate-id "span-")])
    (run-ashlar ashlar (make-dag))))
 
(displayln (node-text result))

current-trace-id correlates every event from this ashlar run; every ashlar’s span carries the same trace id. current-span-id is the root span; deeper ashlars get their own span ids with the root’s as parent.

4.2.2.5 5. Run and inspect🔗ℹ

Run the file and open trace.jsonl. You should see lines like:

#hash((level . info) (data . #hash((event . api-call) (ashlar-name . summarize) (trace-id . "run-...") (span-id . "span-...") ...)))

#hash((level . info) (data . #hash((event . api-response) (ashlar-name . summarize) (usage . #hash((prompt_tokens . 42) ...))) ...))

One 'api-call and one 'api-response for every LLM turn; lifecycle events for every ashlar that ran; error events for every failure node created.

4.2.3 Reading the trace🔗ℹ

Once you have a trace.jsonl, the raco stone trace subcommands cover the canonical investigation flow:

  • Start with tally. raco stone trace tally trace.jsonl prints total event count and per-event-type counts. Use it as a first-pass health check — unexpected counts (e.g. lots of 'sse-trip-raised or 'hypothesis-mismatch) point at where to look next.

  • Then walk lifecycle. raco stone trace lifecycle trace.jsonl prints the ashlar-by-ashlar story of the run: starts, ends, tool dispatches, api-calls. One line per event, in chronological order. Use it to find the moment something went wrong.

  • Then dump payload. raco stone trace payload trace.jsonl ashlar <name> turn <n> shows what the agent actually sent on a specific turn — system prompt, every message, every tool call. This is what you read when the lifecycle view tells you a turn went sideways and you want to know why. last picks the most recent matching payload.

"api-call-payload" events are only emitted at the 'debug log level. If payload returns No api-call-payload found, re-run your ashlar with 'debug level on the log receiver (or STONE_LOG_LEVEL=debug in environments that honor it) so the full message contents land in the trace.

For programmatic access to the trace from Racket code (custom inspection tools, ad-hoc scripts), see stone/trace.

4.2.4 Troubleshooting🔗ℹ

  • Empty trace file. The receiver is probably attached to the wrong logger. stone-logger has #:parent #f so it doesn’t propagate to the root logger — a receiver on (current-logger) sees nothing. Use stone-logger explicitly.

  • Missing events. Raise the level on the receiver. 'info misses 'ashlar-start/'ashlar-end; switch to 'debug.

  • Trailing events lost. The background thread may still be draining when your program exits. If you care about every event, call flush-output on trace-out and sleep for a few tens of milliseconds before exit, or drain the receiver with sync/timeout in a tight loop.

4.2.5 See also🔗ℹ

  • Observability — the full design of Stone’s logging: correlation parameters, event vocabulary, failure auto-logging.

  • Debug an ashlar in the how-to section — using a live receiver to watch events during development.

4.3 Validate a topology🔗ℹ

This guide shows how to run static validation on a Stone ashlar before executing it. Validation walks the topology tree, reads the metadata every ashlar carries, and reports structural mistakes — missing producers, unsafe lenses, match branches that don’t all produce the types downstream consumers rely on. It runs no ashlars, calls no LLMs, and finishes in milliseconds. For the conceptual model see Validation.

4.3.1 Prerequisites🔗ℹ

4.3.2 1. Validate from the REPL or a script🔗ℹ

Call validate-ashlar on a ashlar-meta value:

(require stone/validate)
 
(define result (validate-ashlar ashlar))
(if (validation-ok? result)
    (displayln "valid")
    (for ([err (validation-errors result)])
      (displayln (format "[~a] ~a: ~a"
                         (validation-error-type err)
                         (or (validation-error-ashlar-name err) "?")
                         (validation-error-message err)))))

validate-ashlar returns a validation-result struct. validation-errors pulls the error list out of it; validation-ok? is true when the list contains no hard errors. Each validation-error has four fields: type, ashlar-name, queried-type, and message. The type field is a symbol ('missing-producer, 'maybe-unavailable, 'invalid-lens, or 'fanout-not-reduced) that tells you which class of problem the validator hit.

4.3.3 2. Validate from the command line🔗ℹ

Stone provides a validate subcommand on raco stone. It takes a path to an ashlar file, loads it via dynamic-require, and runs validate-ashlar on the binding named ashlar:

raco stone validate my-ashlar.rkt

The ashlar file must be a Racket module that provides a binding named exactly ashlar, bound to a ashlar-meta? value:

(require stone stone/llm-ashlar stone/llm-client)
 
(provide ashlar)
 
(define caller (make-openai-caller #:url "http://localhost:8000"))
 
(define ashlar
  (~> ashlar-an ashlar-b))

On success the CLI prints one line and exits 0:

$ raco stone validate my-ashlar.rkt

Ashlar is valid.

When the topology has hard errors, you get a header and one line per error formatted as [<type>] <ashlar-name>: <message>, and the CLI exits 1:

$ raco stone validate broken.rkt

Errors (1):

  [missing-producer] summarize: summarize queries 'draft but no upstream Stone produces it

Warnings ('maybe-unavailable) print in a separate Warnings (N): block and don’t change the exit code.

4.3.4 3. Interpret common errors🔗ℹ

4.3.4.1 'missing-producer🔗ℹ

Meaning. An ashlar declares #:queries '(foo) but no ashlar upstream produces a 'foo node.

Common causes.
  • Typo in the queried type name (querying 'configuration but producing 'config).

  • The producer lives in a sibling branch of a ashlar-match or ashlar-parallel that doesn’t always run.

  • The producer was removed or renamed during a refactor and the consumer wasn’t updated.

Fix. Rename the query to match an upstream producer, or ensure a producer exists on every path that reaches the consumer.

4.3.4.2 'maybe-unavailable🔗ℹ

Meaning. A queried type is produced in some branches of a ashlar-match or ashlar-parallel but not every branch. The validator can’t tell at compose time which branch will execute, so it flags the type as uncertain.

Interpretation. This is a warning. If the runtime behavior of your match guarantees that the producing branch runs whenever the query is reached, the warning is safe to ignore. If not, it’s a real bug that will bite you the first time the other branch fires.

Fix. Move the producer outside the branch, add the producer to every branch (even as a defaulted stub), or handle the absence at runtime by checking dag-nearest-ancestor for #f.

4.3.4.3 'invalid-lens🔗ℹ

Meaning. A ashlar-match uses a lens extractor like (lens 'foo 'bar), and the previous ashlar’s JSON schema doesn’t have a foo property at the top level.

Common causes.
  • Typo in the lens path.

  • Schema change in the upstream ashlar that the lens wasn’t updated for.

  • Lens path expects a nested field but the schema is flat.

Fix. Update the lens to match the upstream schema, or update the schema to match the lens. If the extraction is inherently dynamic, move it into a regular ashlar body that can handle missing fields.

4.3.4.4 'fanout-not-reduced🔗ℹ

Meaning. A ashlar-map or ashlar-parallel is positioned so its arbitrary "last lane" return value would flow to a downstream consumer without a ashlar-reduce between them. The rule exists to force an explicit collapse via dag-query-all inside a reducer so downstream ashlars see a principled aggregate instead of a source-order accident.

Fix. Pair the fan-out with a ashlar-reduce whose inner ashlar reads the lane outputs via dag-query-all and produces an aggregate. For a fan-out inside another composite (loop body, match branch, nested fan-out), wrap the inner fan-out in its own sequence ending with a reducer.

4.3.5 4. Integrate validation into your workflow🔗ℹ

4.3.5.1 As a pre-run check🔗ℹ

Add a one-line guard at the top of your entry point so a broken ashlar fails fast instead of eating LLM tokens:

(define result (validate-ashlar ashlar))
(unless (validation-ok? result)
  (for ([err (validation-errors result)])
    (displayln (format "VALIDATION: ~a" (validation-error-message err))))
  (exit 1))
4.3.5.2 As a CI gate🔗ℹ

Run the CLI in a test step. It returns 0 on valid ashlars (and on warnings-only runs) and non-zero on hard errors. The whole pass is millisecond-fast:

raco stone validate my-ashlar.rkt || exit 1

4.3.5.3 In tests🔗ℹ

Use validate-ashlar inside a rackunit test alongside your other compose-time checks:

(require rackunit stone/validate)
 
(test-case "ashlar passes validation"
  (check-true (validation-ok? (validate-ashlar ashlar))))

4.3.6 5. Enumerate ashlars in a topology🔗ℹ

enumerate-ashlars returns a flat list of every ashlar’s name. Use it for inventories, tables of contents, topology diagrams, or counts:

(enumerate-ashlars ashlar)
; => '(ashlar-an ashlar-b ashlar-c ...)

enumerate-paths returns the distinct execution paths through the topology, each a list of ashlar names:

(enumerate-paths ashlar)
; => '((ashlar-an ashlar-b ashlar-c)
;  (ashlar-an ashlar-b ashlar-d))

Sequences concatenate along a single path. Matches and parallels fork. Loops collapse to a single representative path — one iteration of the body.

4.3.6.1 Orphan-ashlar guard🔗ℹ

enumerate-ashlars returns every ashlar reachable from the ashlar root. An ashlar defined and exported but never wired into the ashlar won’t show up — which makes diffing that list against your declared ashlars a precise dead-code check:

(define declared '(load-config classify implement summarize cleanup))
(define wired    (list->set (enumerate-ashlars ashlar)))
(define orphans  (filter (lambda (s) (not (set-member? wired s)))
                         declared))
(unless (null? orphans)
  (error 'my-ashlar "declared but not wired: ~a" orphans))

The validator can’t catch an orphan — an unused ashlar isn’t a structural error — but this adjacent check will.

4.3.7 Troubleshooting🔗ℹ

  • "does not export ’ashlar’". The CLI calls dynamic-require on the file and looks for a binding named exactly ashlar. Rename your exported topology to ashlar, or add a thin alias (define ashlar my-real-topology) alongside the export.

  • Validator passes but the ashlar fails at runtime. Static validation only catches structural mismatches. LLM responses that don’t match the schema, network failures, tool errors, and bad healer output still happen at runtime — see Debug an ashlar.

  • 'maybe-unavailable in output that should be fine. Check whether your ashlar-match branches all produce the same types. If they do but the validator still warns, restructure the match or accept the warning as a known soft signal.

4.3.8 See also🔗ℹ

4.4 Write a custom ashlar🔗ℹ

This guide shows how to wrap your own work as an ashlar with make-ashlar. Reach for it when the work isn’t a single LLM call and isn’t a multi-turn agent loop, but something you still want to compose into an ashlar: reading a configuration file, shelling out to a subprocess, parsing output, hitting an external API, or running any deterministic transformation whose result later ashlars want to query.

Because everything in Stone is an ashlar, there’s no second calling convention for "the plain-code bits." You write a (DAG -> node) function, hand it to make-ashlar, declare the metadata the validator needs, and it slots into every composition primitive the framework exposes.

4.4.1 Prerequisites🔗ℹ

4.4.2 1. The shape of a custom ashlar🔗ℹ

A custom ashlar is make-ashlar applied to a function that takes a DAG and returns a node. The function reads whatever it needs from the DAG, does its work, and hands back one node.

(require stone)
 
(define my-ashlar
  (make-ashlar
    (lambda (dag)
      (typed-node dag 'my-type
        (hasheq 'result "something")))
    #:produces 'my-type
    #:name 'my-ashlar))

typed-node is a convenience over make-typed-node that defaults the parents to (dag-heads dag) — the DAG frontier the ashlar was just handed. That’s what you want 99% of the time. Reach for the full make-typed-node form when you need to attach custom meta or point at parents other than the current heads.

make-ashlar takes the function as its first positional argument and then keyword metadata. #:produces declares the node type the ashlar will emit; #:name is the symbol used in logs, validator output, and error messages.

4.4.3 2. Reading from the DAG🔗ℹ

Use dag-nearest-ancestor to pull the node of a type on the current execution lineage and dag-query-all to pull every node of a type. node-get and node-text are the shape-safe readers:

(define extract-config-name
  (make-ashlar
    (lambda (dag)
      (define cfg  (dag-nearest-ancestor dag 'project-config))
      (define name (node-get cfg 'language "unknown"))
      (typed-node dag 'config-name
        (hasheq 'name name)))
    #:produces 'config-name
    #:queries '(project-config)
    #:name 'extract-config-name))

node-get returns the default when the node is #f (nothing upstream), when the content isn’t a hash, or when the key is missing. For nested paths, node-get* ((node-get* cfg 'deployment 'region)); for plain text output, node-text.

Declare every type you read in #:queries. The validator uses the list to check that some upstream ashlar actually produces each type.

4.4.4 3. Producing a typed node🔗ℹ

Two constructors. typed-node is the ergonomic form — takes the DAG and defaults parents to the current heads:

(typed-node
  dag
  'my-type
  (hasheq 'key "value"))

make-typed-node is the full form — use it when you need custom meta or different parents:

(make-typed-node
  (dag-heads dag)
  'my-type
  (hasheq 'key "value")
  (hasheq 'tag "extra"))

Use hasheq rather than hash for content. Symbol keys are faster to look up, match the convention every other ashlar follows, and keep node-content reads on one discipline.

4.4.5 4. Handling failure🔗ℹ

When an ashlar can’t complete its job, return a failure node instead of a typed one. make-failure-node builds it:

(define check-file-exists
  (make-ashlar
    (lambda (dag)
      (define cfg  (dag-nearest-ancestor dag 'config))
      (define path (node-get cfg 'file-path))
      (cond
        [(and path (file-exists? path))
         (typed-node dag 'file-ok
           (hasheq 'path path))]
        [else
         (make-failure-node (dag-heads dag) 'file-missing
           (format "required file not found: ~a" path))]))
    #:produces 'file-ok
    #:queries '(config)
    #:name 'check-file-exists))

A failure node stops ~> immediately and exits an ashlar-loop. stone/logging auto-emits an 'error-level event with the failure kind and reason — see Observability. For recovery patterns see Handle failures in an ashlar.

4.4.6 5. Side effects are fine🔗ℹ

A custom ashlar can write files, call subprocesses, or hit external APIs. The framework doesn’t try to make ashlars pure. Just make sure the returned node describes the result of the side effect so downstream ashlars can find it:

(define (make-write-file project-root)
  (make-ashlar
    (lambda (dag)
      (define spec     (dag-nearest-ancestor dag 'file-spec))
      (define content  (node-get spec 'body))
      (define rel-path (node-get spec 'path))
      (define abs-path (build-path project-root rel-path))
      (display-to-file content abs-path #:exists 'replace)
      (typed-node dag 'file-written
        (hasheq 'path (path->string abs-path))))
    #:produces 'file-written
    #:queries '(file-spec)
    #:name 'write-file))

Parameterize factories like this one by accepting construction-time configuration — here, project-root — and returning the ashlar. The ashlar’s function closes over the config.

4.4.7 6. Choosing the right constructor🔗ℹ

  • make-ashlar — deterministic work, side effects, any (DAG -> node) logic. This guide.

  • make-agent-ashlar — an LLM ashlar with optional middleware, tools, and multi-turn loops. Use for any work that involves an LLM call, whether it’s a single prompt-and-response or a multi-turn agent.

If your work fits make-agent-ashlar, use it — it handles retries, schema parsing, lifecycle events, and conversation attachment for you. Reach for make-ashlar only when the work doesn’t involve an LLM call.

4.4.8 7. Declaring metadata correctly🔗ℹ

A checklist:

  • #:produces type — the symbol of the node type you will emit. Required for downstream validation.

  • #:queries '(type1 type2 ...) — every type the function reads. The validator checks these against upstream producers before the ashlar runs.

  • #:name 'ashlar-name — a symbol used in logs and error messages. Defaults to #:produces when omitted. Set it explicitly for readable traces.

4.4.9 Troubleshooting🔗ℹ

  • Ashlar compiles but validate-ashlar reports 'missing-producer. You queried a type that nothing upstream produces. Fix the query name, add a producer, or restructure the topology.

  • Ashlar runs but downstream ashlars can’t find its output. Check #:produces. If the declared type doesn’t match what downstream ashlars query for, the emitted node is invisible to them.

  • Side effect fires but the ashlar claims failure. You returned a failure node after doing the side effect. Either check preconditions before the side effect and fail early, or return a success node that describes what the side effect did.

  • Ashlar function crashes with an unhandled exception. The framework doesn’t catch exceptions — they propagate up and abort the ashlar. Wrap risky calls in with-handlers and return a failure node from the handler.

4.4.10 See also🔗ℹ

4.5 Route on a node from earlier in the ashlar🔗ℹ

ashlar-match’s procedure extractor receives the full work DAG, so the branch decision can come from any node reachable from a head — not just the immediately preceding one. Reach for this shape when a classifier runs early and intermediate steps land between it and the routing decision. For the conceptual model see Edge Primitives.

4.5.1 Prerequisites🔗ℹ

  • An ashlar with a classifier ashlar that emits a typed node (for example, 'classification) and one or more intermediate ashlars between the classifier and the routing decision.

  • Comfort building ashlars with make-ashlar and composing with ~>. See Write a custom ashlar if either is new.

4.5.2 1. Use a procedure extractor, not a lens🔗ℹ

A lens? extractor is applied to the latest head’s content, which is whatever the immediately preceding ashlar produced — not your classifier’s output. To reach an earlier node, pass a procedure instead. The procedure receives the work DAG and is free to walk anywhere in it.

4.5.3 2. Walk to the classifier’s node🔗ℹ

Inside the extractor, call dag-nearest-ancestor with the classifier’s #:produces type. The walk follows the first-parent line back from the latest head until it finds a node of that type.

4.5.4 3. Project the routing key with node-get🔗ℹ

Pull the field you want to branch on out of the classifier’s node with node-get (or the accessor your classification uses). Return that value from the extractor — ashlar-match runs the branch whose val equals it.

(~> classify-request   ; produces 'classification {kind: ...}
    gather-context     ; produces 'context; this is the head at match time
    (ashlar-match
      (lambda (d)
        (node-get (dag-nearest-ancestor d 'classification) 'kind))
      ["support" support-flow]
      ["billing" billing-flow]))

4.5.5 Troubleshooting🔗ℹ

  • The match returns a 'match-failed failure node. Confirm the type passed to dag-nearest-ancestor matches the classifier’s #:produces symbol exactly, and that the classifier actually ran before the match (its output must be reachable on the first-parent line from the current head).

  • The extractor returns #f because the ancestor wasn’t found. dag-nearest-ancestor returns #f when no node of the requested type exists on the first-parent line, and node-get called on #f returns #f. Add an explicit #f branch, or guard the walk before projecting, rather than relying on the default 'match-failed error.

  • The wrong branch fires after a loop or fan-out. dag-nearest-ancestor walks the first-parent line, so an intermediate composite that rewrote the head can shift which 'classification node it finds. If a loop or fan-out sits between the classifier and the match, sanity-check the walk by printing the ancestor’s content in the extractor.

4.5.6 See also🔗ℹ

4.6 Add structured output to an LLM ashlar🔗ℹ

This guide shows how to constrain an LLM ashlar’s output to a JSON schema so downstream ashlars can read typed fields off the resulting node. For the conceptual model see Ashlars.

4.6.1 Prerequisites🔗ℹ

  • A working LLM ashlar. If you don’t have one, work through Getting Started first.

  • An LLM provider that supports JSON schema response format. Most OpenAI-compatible endpoints do, and Anthropic’s Messages API does through the caller shim.

If you’re running on vLLM and also need tools, the ashlar-pair pattern applies — a single agent can’t combine schema enforcement with tool calls on that provider. See The ashlar-pair pattern.

4.6.2 1. Define a schema🔗ℹ

Use make-json-schema (re-exported from stone):

(require stone)
 
(define project-config-schema
  (make-json-schema "project_config"
    (hasheq 'language       (hasheq 'type "string"
                                    'description "Programming language")
            '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")))

Three arguments: a name (surfaced to the provider), a hasheq mapping field names to property specs (standard JSON Schema vocabulary), and a list of required field names.

make-json-schema wraps your properties in the full response-format envelope (type: "json_schema", strict: #t, additionalProperties: #f) and returns a hash you can pass straight to #:response-format.

4.6.3 2. Set #:response-format on the ashlar🔗ℹ

(require (except-in stone context context?)
         stone/llm-ashlar stone/context-struct)
 
(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 configs from a feature description. Return JSON matching the project_config schema.")
                (user 'requirement))
    #:name 'propose-config))

When the ashlar runs it forwards the response format to the caller, which forwards it to the API. On return, the ashlar parses the response text as JSON and produces a typed node whose content is the parsed hash. Your system prompt should still mention that JSON is expected; the schema constrains the shape, but telling the model what it’s looking at helps.

4.6.4 3. Read the structured output downstream🔗ℹ

(define use-config
  (make-ashlar
    (lambda (dag)
      (define cfg       (dag-nearest-ancestor dag 'project-config))
      (define language  (node-get cfg 'language))
      (define framework (node-get cfg 'test_framework))
      (typed-node dag 'setup-plan
        (hasheq 'summary (format "~a project using ~a" language framework))))
    #:produces 'setup-plan
    #:queries '(project-config)
    #:name 'use-config))

The parsed content is a Racket hash with symbol keys — JSON objects become hasheq values during parsing. Use (node-get cfg 'language) with a symbol, not a string.

4.6.5 4. Handle parse failures🔗ℹ

If the LLM’s response can’t be parsed as JSON, or is empty, the ashlar doesn’t raise. It produces a failure node instead. The failure node’s kind is 'llm-parse-failed.

This matters because downstream ashlars that expect a 'project-config won’t see one — a ~> sequence stops on the first failure. To recover, wrap the ashlar in a ashlar-loop:

(define discover-config
  (ashlar-loop propose-config
    #:until has-project-config?
    #:max 3))

Where has-project-config? checks both the node type and the content shape:

(define (has-project-config? node)
  (and (equal? (node-type node) 'project-config)
       (hash? (node-content node))
       (hash-has-key? (node-content node) 'language)))

The predicate short-circuits on failure nodes (whose type is 'failure, not 'project-config) and the loop retries.

4.6.6 5. Fold a human step into the body when retries aren’t enough🔗ℹ

If the LLM keeps failing to produce valid JSON, or the predicate keeps rejecting it, blind retries don’t help. Add a human-in-the- loop step as a sibling inside the body so it runs between iterations:

(define discover-config
  (ashlar-loop
    (~> propose-config
        (ashlar-match (lens 'needs-help?)
          [#t ask-for-config-details]
          [#f noop]))
    #:until has-project-config?
    #:max 5))

The match runs the ask only when the proposal couldn’t satisfy the schema on its own. See Add human interaction to an ashlar for the ask-human wiring and Edge Primitives for the body-fold pattern.

4.6.7 6. Auto-derived schema metadata🔗ℹ

When you pass #:response-format, the ashlar-meta’s schema field is auto-populated from the JSON schema. This means validate-ashlar can check downstream lens accesses against the schema without you declaring #:schema separately.

A ashlar-match using (lens 'language) to branch on the config validates against the project_config schema’s language field. If you rename the field in the schema but forget to update the lens, validate-ashlar catches the mismatch before a single LLM call runs.

4.6.8 7. Dispatching to different node shapes with #:finalize🔗ℹ

When the parsed JSON needs to dispatch between different node types — a pass produces one kind of node, a reject produces a failure node with structured feedback — reach for #:finalize. The hook receives the parsed content and returns either a typed node or a make-failure-node:

(define verdict-schema
  (make-json-schema "verdict"
    (hasheq 'ok     (hasheq 'type "boolean")
            'reason (hasheq 'type "string"))
    '("ok" "reason")))
 
(define review-implementation
  (make-agent-ashlar caller
    #:produces 'review-passed
    #:response-format verdict-schema
    #:max-turns 1
    #:middleware '()
    #:finalize
    (lambda (parsed)
      (if (hash-ref parsed 'ok #f)
          (make-typed-node '() 'review-passed parsed)
          (make-failure-node '() 'review-rejected
            (hash-ref parsed 'reason "no reason given"))))
    #:context (context (system "Review the implementation…"))))

Without #:finalize, the parsed content would always become a 'review-passed node — even when ok was #f. With it, the reject path produces a failure node that downstream ~> recognizes and propagates. See Agents and Tools for the full #:finalize contract including conversation injection behavior.

4.6.9 Troubleshooting🔗ℹ

  • Stone produces 'llm-parse-failed. The LLM response was empty or not valid JSON. Check that your provider supports response_format: json_schema. Check that the system prompt clearly asks for JSON. Wrap in a ashlar-loop to retry.

  • hash-ref fails downstream. The parsed content is a hash with symbol keys, not string keys.

  • Schema works locally but fails against a different provider. Different providers have slightly different JSON-schema support. If switching, test end to end — the contract sits between your caller and the remote API.

  • Agent ashlar never emits a final answer with tools attached. On vLLM, schema + tools usually fails — see The ashlar-pair pattern. On Anthropic, ensure #:decide halts once the tool phase is done.

4.6.10 See also🔗ℹ

4.7 Add human interaction to an ashlar🔗ℹ

This guide shows how to pause a running ashlar for human input, how to wire a supervisor that answers through the run’s threshold, and how to compose human ashlars with approval loops and learning loops. For the conceptual model see Ask Human.

4.7.1 Prerequisites🔗ℹ

4.7.2 1. Add an ask-human ashlar🔗ℹ

make-ask-human takes four keyword arguments and no channel: a formatter that builds the question, a #:name, a #:produces symbol for the answer node, and an optional #:queries list of node types the formatter reads.

(require stone stone/threshold stone/record)
 
(define ask-approve
  (make-ask-human
    #:format-fn (lambda (dag)
                  (define proposal (dag-nearest-ancestor dag 'proposal))
                  (format "Approve proposal: ~a? (yes/no)"
                          (node-get proposal 'summary)))
    #:name 'ask-approve
    #:produces 'human-response
    #:queries '(proposal)))

At run time the ashlar calls the formatter, poses the question through (current-threshold), blocks until a supervisor resolves it, and produces a typed 'human-response node carrying the answer (readable with (node-text node) or (node-get node 'human-response)). A cancelled ask produces a halt node instead — 'declined on a supervisor’s decline, 'user-quit on an unattended quit.

4.7.3 2. Run under a record and wire a supervisor🔗ℹ

An ask-human ashlar solicits through (current-threshold), which is bound only under a durable run. So run it with run-ashlar #:record (or under the TUI, Use the TUI) — a bare (run-ashlar ashlar dag) binds no threshold.

A supervisor is a thread that threshold-subscribes to the run’s crossing feed, watches for an 'ask crossing, and answers it. Attach first so decisions block for the supervisor instead of auto-resolving from the unattended policy:

(require racket/list stone stone/threshold stone/record)
 
(define th (make-threshold))
(threshold-attach! th)
(define sub (threshold-subscribe th))
(define r   (open-record "session.jsonl" #:threshold th))
 
(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)))))
 
(start-stdin-supervisor! th sub)
(define final-dag (run-ashlar my-ashlar #:record r))

The supervisor subscribes to one feed and sees every crossing the run surfaces — asks, tool-call gates, and token/turn observations. It routes each 'ask back by its 'id; nothing knows what ashlars exist.

4.7.4 3. Use an approval loop pattern🔗ℹ

Approval loops ask the human to confirm a result. The ask-human ashlar lives inside the body because the iteration isn’t done until the human has weighed in:

(define approve-or-retry
  (ashlar-loop (~> propose verify ask-approve)
    #:until approved?
    #:max 5))

Where propose drafts a change, verify runs static checks, ask-approve asks the human, and approved? inspects the response:

(define (approved? node)
  (and (equal? (node-type node) 'human-response)
       (regexp-match? #rx"(?i:^(y|yes|ok|approve))"
                      (node-get node 'human-response ""))))

On rejection the whole body runs again. This is the shape you want whenever the predicate can’t be satisfied without the human.

4.7.5 4. Use a learning loop pattern🔗ℹ

Learning loops ask the human only when the body couldn’t figure something out on its own. Fold an ashlar-match into the body so the ask only runs on the unhappy path:

(define discover-config
  (ashlar-loop
    (~> discover
        (ashlar-match (lens 'needs-help?)
          [#t ask-for-language]
          [#f noop]))
    #:until has-project-config?
    #:max 5))

discover tries to produce a project config from whatever is already in the DAG and sets 'needs-help? based on whether required fields are present. The ashlar-match fires ask-for-language on missing fields or noop on a clean pass. Iteration N+1’s discover sees the answer landed by iteration N’s ask.

4.7.6 5. Branch on a human response🔗ℹ

For routing decisions, use ashlar-match with a lens extractor:

(define after-approval
  (ashlar-match (lens 'human-response)
    ["yes" proceed-ashlar]
    ["no"  cancel-ashlar]))

Cases match exactly, so normalize the response upstream if you want to handle "y", "yes", "Y", and "ok" the same way — a small make-ashlar that rewrites the response into a canonical form, placed between the ask-human ashlar and the match, is the usual shape.

4.7.7 6. Decline a pending question🔗ℹ

A supervisor can decline a pending question with threshold-cancel! on the ask’s id. The default kind is 'declined, which the ashlar turns into a 'declined halt while the rest of the run keeps going (a ~> sequence short-circuits on the halt):

(threshold-cancel! th id)              ; declined
(threshold-cancel! th id 'user-quit)   ; quit the session

When a decline and a quit should route differently, ashlar-match automatically sends a halt head to the branch keyed by its halt kind ('declined / 'user-quit) — add those keys alongside your normal answer branches.

4.7.8 7. Test with a scripted supervisor🔗ℹ

For unit tests, drive the threshold directly from a scripted thread. Attach, subscribe, and answer the asks as they cross. Start the supervisor before run-ashlar so the first question has a resolver waiting:

(require rackunit)
 
(test-case "ashlar handles rejection then approval"
  (define th (make-threshold))
  (threshold-attach! th)
  (define sub (threshold-subscribe th))
  (define r   (open-record (make-temporary-file "sess-~a.jsonl")
                           #:threshold th))
  (thread
    (lambda ()
      (define (answer-next text)
        (let loop ()
          (define c (sync sub))
          (if (eq? (crossing-kind c) 'ask)
              (threshold-answer! th (hash-ref (crossing-payload c) 'id) text)
              (loop))))
      (answer-next "no")
      (answer-next "yes")))
  (define final-dag (run-ashlar my-ashlar #:record r))
  (check-true (approved? (dag-latest-head final-dag))))

4.7.9 Troubleshooting🔗ℹ

  • Ask hangs forever. The run has no supervisor and the threshold is still attended (threshold-attach!ed) — nobody answers. Either answer from a supervisor thread, or leave the threshold unattended so the ask auto-resolves to a 'user-quit halt from policy.

  • Ask never blocks; you get a 'user-quit halt immediately. The threshold is unattended. Call threshold-attach! before running so a supervisor can answer.

  • Ask errors under a bare run. (current-threshold) is #f outside run-ashlar #:record / the TUI. Run under a record.

  • Tests block on human input. The scripted supervisor thread wasn’t started before run-ashlar, or it isn’t filtering for 'ask crossings (the feed also carries gates and observations).

4.7.10 See also🔗ℹ

4.8 Run a durable, resumable session🔗ℹ

A bare (run-ashlar ashlar dag) is pure and ephemeral: it runs once, in memory, and forgets. A record makes a run durable — every completed step is journaled to disk as it happens, so a crashed or quit run resumes from where it stopped instead of starting over. This is what raco stone and the TUI run on, and what any interactive topology (one with make-ask-human) needs, because a threshold-answered decision only makes sense inside a durable run.

This guide covers opening a record, running durably, resuming after a crash, and the raco stone sessions / continue workflow. For the on-disk format see stone/journal; for the threshold see Ask Human.

4.8.1 Record, journal, context — three distinct things🔗ℹ

The durability model rests on keeping three ideas separate:

  • The record — what is stored: the DAG plus its durable journal (and the threshold a supervisor observes through). open-record gives you one.

  • The journal — the record’s on-disk form: an append-only JSONL log. The DAG is the single source of truth; the journal is its crash-recovery projection. Reconstructing the DAG from the journal is exactly what a resume does.

  • The context — what one run sends to the model: bounded, computed fresh each run by projecting the record’s DAG through an agent’s #:context lenses. The record is never the context; the context is a projection of the record. A long session accumulates an unbounded record but sends a bounded context each turn.

Keeping these apart is why a session can run for hours: the record grows, the context stays small (and an agent’s #:compaction ashlar summarizes when a projection would overflow — see stone/llm-ashlar).

4.8.2 1. Open a record🔗ℹ

open-record is the single entry point for both a fresh run and a resume. path is the journal file — the identity of the session on disk.

(require stone stone/record stone/threshold)
 
(define th (make-threshold))
(define r  (open-record "session.jsonl" #:threshold th))
  • Fresh (no file at path) — an empty DAG plus a new journal opened for append.

  • Resume (the file exists) — the DAG is rebuilt by replaying the journal, and the same file is reopened for append so new facts extend the log.

The #:threshold defaults to #f; pass a real one for an interactive or supervised run (a batch test may pass one to observe or answer decisions).

4.8.3 2. Run durably🔗ℹ

Pass the record to run-ashlar with #:record. It runs one forward pass, journaling each atomic completion and binding the record’s threshold as (current-threshold):

(define final-dag (run-ashlar ashlar #:record r))

dag and #:record are mutually exclusive — the record supplies the DAG. For an interactive topology, attach a supervisor to the threshold first (see Add human interaction to an ashlar).

4.8.4 3. Resume after a crash🔗ℹ

Resuming is not a special mode — it is the same open-record + run-ashlar over the same path. If the first run crashed (or was quit) partway, the journal holds the completed steps; the second run replays them and continues live from the crash point:

; First run — crashes after some steps are journaled.
(define r1 (open-record "session.jsonl" #:threshold (make-threshold)))
(run-ashlar ashlar #:record r1)   ; ... crash ...
 
; Later: same path. Completed leaves replay; the rest runs live.
(define r2 (open-record "session.jsonl" #:threshold (make-threshold)))
(run-ashlar ashlar #:record r2)

What replays, precisely:

  • Only atomic (leaf) ashlars replay; composites (~>, ashlar-loop, …) always re-walk their children, so a loop’s remaining iterations resume correctly.

  • Leaves replay by position in the deterministic walk: the first N leaves (N = the count of recorded atomic completions) skip their bodies — no LLM call, no side effect, no re-journal — and the rest run live. Answered decisions replay their recorded answers; a decision that was posed but never answered is re-posed.

  • Topology guard. The journal records a hash of the topology shape — including the context structures. Replaying against edited code fails loudly with topology changed since this session rather than replaying into a mismatched walk.

4.8.5 4. Session loop: run until a terminal halt🔗ℹ

An interactive session doesn’t do one pass — it loops, taking user turns until the user quits. #:loop #t (which requires #:record) folds the topology over the record unbounded until the terminal head is a session-terminal halt — a 'user-quit or 'input-ended halt node (make-ask-human produces the former on an unattended or quit cancel):

(run-ashlar ashlar #:record r #:loop #t)

This is exactly what run-tui runs on its background thread. Only put a topology with a terminal-halt path into #:loop; with none, it never returns.

4.8.6 5. Sessions on the command line🔗ℹ

raco stone manages records for you as sessions under .stone/sessions/<id>.jsonl:

Command

  

Effect

raco stone

  

Start a fresh session (new journal id) under the TUI.

raco stone sessions

  

List recorded sessions: id, started, last activity, and the first prompt as a label.

raco stone continue

  

Resume the most-recent session.

raco stone continue <id>

  

Resume the named session.

Resuming replays the journal as TUI scrollback (display and crash recovery are the same journal read) and continues the run. See Command-line interface and Use the TUI.

4.8.7 See also🔗ℹ

4.9 Handle failures in an ashlar🔗ℹ

This guide is a catalog of patterns for when an ashlar can’t do its work. Ashlars signal failure by returning a failure node rather than raising; the composition primitives recognize that shape and propagate it outward.

For the conceptual model, see Ashlars, Edge Primitives, and Agents and Tools (for the adversary + heal-with pattern on make-agent-ashlar).

4.9.1 Prerequisites🔗ℹ

4.9.2 1. Return a failure node from your ashlar🔗ℹ

When an ashlar can’t complete its job, build a failure node with make-failure-node instead of a typed one. The signature is (make-failure-node parents kind reason [meta]): kind is a symbol naming the class of failure, reason is a human-readable message, and the optional meta is a hash of extra context.

(require stone)
 
(define check-file-exists
  (make-ashlar
    (lambda (dag)
      (define cfg  (dag-nearest-ancestor dag 'config))
      (define path (node-get cfg 'file-path))
      (cond
        [(and path (file-exists? path))
         (typed-node dag 'file-ok (hasheq 'path path))]
        [else
         (make-failure-node (dag-heads dag) 'file-missing
           (format "required file not found: ~a" path))]))
    #:produces 'file-ok
    #:queries '(config)
    #:name 'check-file-exists))

You don’t need to log the failure yourself. stone/logging installs a hook on failure-log-handler that make-failure-node fires on every call — every failure node becomes an 'error-level event in the trace automatically.

One subtlety: make-ashlar’s wrapper appends successful nodes to the DAG but leaves the DAG unchanged when the function returns a failure. The failure node is a value in flight — it propagates through the composition primitives but doesn’t land in the DAG itself.

4.9.3 2. Understand what propagates when🔗ℹ

Each composition primitive has its own rule:

  • ~> (sequence) — stops at the first failure and returns it. Subsequent ashlars in the chain are skipped.

  • ashlar-loop — a body failure exits immediately with that failure. A predicate never satisfied within #:max exits with a fresh 'loop-exhausted failure.

  • ashlar-match — no head node or no matching branch produces its own 'match-failed failure. A branch that itself returns a failure propagates through unchanged.

  • ashlar-map / ashlar-parallel — failing lanes are dropped from the DAG append step. The composite’s returned node is the last lane’s output; if that’s a failure, the composite reports it even though earlier successful lanes did land in the DAG. ashlar-map with an empty item list produces 'map-empty; ashlar-parallel with no lanes produces 'parallel-empty.

A failure at any depth propagates outward without exceptions. Every primitive above the failure gets a chance to recognize it, branch on it, or wrap it.

4.9.4 3. Check for failure at the top level🔗ℹ

run-ashlar always returns (values result dag). Check with failure-node? before trusting it:

(define-values (result final-dag)
  (run-ashlar my-ashlar (make-dag)))
 
(cond
  [(failure-node? result)
   (displayln (format "Ashlar failed: ~a — ~a"
                      (node-get result 'kind)
                      (node-get result 'reason)))
   (exit 1)]
  [else
   (displayln "Ashlar completed.")
   (displayln (node-content result))])

The failure node’s content is a hasheq with 'kind (a symbol like 'llm-parse-failed) and 'reason (a string). The final-dag is the partial result — inspect it with dag-nodes if you need to see what landed before the failure.

4.9.5 4. Retry with ashlar-loop🔗ℹ

If an ashlar is flaky — an LLM reply that sometimes needs another pass, a predicate that needs more information — wrap it in a ashlar-loop:

(define retry-3x
  (ashlar-loop propose-config
    #:until has-required-fields?
    #:max 3))

Critical: this retries only when the body succeeded but the predicate said no. If the body returns a failure, the loop exits with it immediately. For retrying on body failure, see section 6.

4.9.6 5. Recover by folding repair into the loop body🔗ℹ

When an ashlar needs external help between attempts — usually human input, sometimes a cleanup step — fold the repair work into the loop body as a sibling ashlar. A simple sequence runs the repair every pass; a ashlar-match inside the body runs it only when the try actually failed.

; Always run the repair step (safe when it's cheap and idempotent)
(define discover-config
  (ashlar-loop (~> propose-config ask-for-missing-fields)
    #:until has-required-fields?
    #:max 5))
 
; Run the repair only when the body's output is incomplete
(define discover-config
  (ashlar-loop
    (~> propose-config
        (ashlar-match (lens 'complete?)
          [#t noop]
          [#f ask-for-missing-fields]))
    #:until has-required-fields?
    #:max 5))

The match-inside-body shape is the one to reach for when the repair has costs you only want to pay when you have to — asking a human a needless question, hitting a paid API, or firing a side effect that makes no sense on the happy path.

Iteration N+1’s body sees every node iteration N produced, so propose-config on the next pass has strictly more information to work with.

For conversation-level healing — where an LLM ashlar’s draft is rejected and the model needs feedback folded into its next turn — see make-agent-ashlar’s #:adversary and #:heal-with pair in Agents and Tools.

4.9.7 6. Retry on body failure, not just predicate miss🔗ℹ

Sometimes you want to retry when the body itself fails — e.g., an LLM call that returned an unparseable response. ashlar-loop exits on body failure by default. The workaround is to reify the failure into data so the body always returns a typed node and the predicate inspects the content:

(define propose-with-retry
  (make-ashlar
    (lambda (dag)
      (define-values (result dag) (propose-config dag))
      (cond
        [(failure-node? result)
         (typed-node dag 'proposal-attempt
           (hasheq 'ok #f
                   'reason (node-get result 'reason)))]
        [else
         (typed-node dag 'proposal-attempt
           (hasheq 'ok #t
                   'result (node-content result)))]))
    #:produces 'proposal-attempt
    #:name 'propose-with-retry))
 
(define retry-loop
  (ashlar-loop propose-with-retry
    #:until (lambda (node) (node-get node 'ok))
    #:max 5))

The wrapping ashlar catches the failure and turns it into a 'proposal-attempt node tagged ok: #f. The loop sees a successful body, runs the predicate, finds ok #f, and iterates. Use this when the failure is expected, recoverable by retry alone, and worth retrying a bounded number of times.

4.9.8 7. Read failure events from the trace🔗ℹ

Because make-failure-node auto-logs, every failure emits an 'error-level event. Filter the trace file:

jq 'select(.level == "error")' trace.jsonl

Narrow by kind — the event field is the failure kind itself, not a generic 'failure:

jq 'select(.data.event == "llm-parse-failed")' trace.jsonl

Every failure event carries trace-id, span-id, and parent-span-id from the enclosing ashlar. See Debug an ashlar for the full vocabulary.

4.9.9 8. Fail fast vs recover🔗ℹ

Fail fast. Every ashlar returns a failure when it can’t do its work, nothing catches anything, and the top-level caller prints the kind and reason and exits non-zero. Good for simple ashlars, tests, and one-off scripts.

Recover. Risky ashlars are wrapped in ashlar-loop with retries, body-level repair, or reified-failure patterns. Good for production ashlars where LLM flakiness or missing context is expected.

Recovery is localized, not global. The usual shape is a fail-fast outer ashlar with recovery islands around the ashlars that need them.

4.9.10 Troubleshooting🔗ℹ

  • Ashlar exits with "Ashlar failed" but I can’t find the originating ashlar. Filter the trace with jq select(.level == "error") — every failure event carries span-id, parent-span-id, and the enclosing ashlar’s name.

  • Stone returned a failure but it isn’t in the final DAG. Expected. make-ashlar’s wrapper leaves the DAG unchanged on failure.

  • ashlar-loop doesn’t retry when the body fails. Correct by design. For retrying on body failure, use the reified-failure pattern from section 6.

  • ashlar-map reports success even though some lanes failed. ashlar-map filters failing lanes out of the appended DAG but returns the last lane’s output. If the last lane happened to succeed, the top-level result looks fine. Use dag-query-all on the final DAG to inspect what landed from each lane.

  • 'match-failed with no obvious cause. Either the DAG had no head node or the extractor returned a value that matches no branch. ashlar-match has no default — add explicit branches for every value you expect.

4.9.11 See also🔗ℹ

4.10 Configure a caller🔗ℹ

A caller is a function that knows how to talk to a specific LLM API. Stone ships two factories — make-openai-caller for OpenAI-compatible servers and make-anthropic-caller for Anthropic’s Messages API — and both accept an #:extra-body hash for passing provider-specific fields that Stone doesn’t bake into the signature.

This guide covers the configurations you’re most likely to need: picking the right factory, setting the model, disabling Qwen3.5’s default thinking mode, and passing provider-specific reasoning controls. For the design reasons these knobs exist, see Provider constraints.

4.10.1 Prerequisites🔗ℹ

  • Stone installed and an ashlar that needs an LLM call.

  • Either an OpenAI-compatible endpoint (vLLM, ollama, OpenAI, Fireworks, Together, etc.) or an Anthropic API key.

4.10.2 1. OpenAI-compatible servers🔗ℹ

For vLLM, ollama, LM Studio, LiteLLM, Fireworks, Together, Groq, DeepSeek, Qwen/DashScope, and OpenAI itself:

(require stone stone/llm-client)
 
(define caller (make-openai-caller #:url "http://localhost:8000"))
(default-model "Qwen/Qwen3.5-35B-A3B")

#:url can be the base URL or the full /v1/chat/completions URL; /v1/chat/completions is appended when missing. #:api-key defaults to empty — no Authorization header is sent. Set it if your provider needs one:

(define caller
  (make-openai-caller #:url "https://api.openai.com/v1/chat/completions"
                      #:api-key (getenv "OPENAI_API_KEY")))
(default-model "gpt-4o-mini")

4.10.3 2. Anthropic🔗ℹ

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

#:url defaults to https://api.anthropic.com/v1/messages — override if you’re proxying through a gateway.

4.10.4 3. Disable Qwen3.5 thinking mode🔗ℹ

Qwen3.5-family models ship with reasoning enabled by default, which blows 60-second harness timeouts on schema-enforced outputs and interferes with tool calls. Disable it via #:extra-body:

(define caller
  (make-openai-caller
    #:url "http://localhost:8000"
    #:extra-body (hasheq 'chat_template_kwargs
                         (hasheq 'enable_thinking #f))))

The caller closes over #:extra-body at construction time, so every request this caller sends carries the flag. No per-call plumbing.

If one ashlar needs thinking and another doesn’t, construct two callers and hand each to the ashlars that want it:

(define reasoning-caller (make-openai-caller #:url "..."))
(define fast-caller
  (make-openai-caller #:url "..."
    #:extra-body (hasheq 'chat_template_kwargs
                         (hasheq 'enable_thinking #f))))

See Provider constraints for why the /no_think soft-switch from earlier Qwen releases isn’t an option on 3.5.

4.10.5 4. Provider-specific reasoning controls🔗ℹ

Different providers expose reasoning as different request fields. Stone doesn’t normalize them. Use #:extra-body:

  • OpenAI o1/o3:
    (make-openai-caller #:url "..."
      #:extra-body (hasheq 'reasoning_effort "low"))

  • xAI Grok: nested object with budget — check the provider’s docs for the exact shape.

  • DeepSeek: model-only — pick deepseek-chat for no reasoning, deepseek-reasoner for chain-of-thought. No #:extra-body needed.

4.10.6 5. Reserved keys🔗ℹ

Stone reserves the keys it uses to thread the model, messages, tools, and schemas. Trying to override them through #:extra-body raises at call time:

  • OpenAI-compatible: model, messages, max_tokens, tools, response_format, stream.

  • Anthropic: model, system, messages, max_tokens, tools, response_format.

4.10.7 6. Multiple callers in one ashlar🔗ℹ

Callers are values. Hand different callers to different ashlars when different parts of an ashlar want different providers, different models, or different thinking settings:

(define planning-caller
  (make-anthropic-caller #:api-key (getenv "ANTHROPIC_API_KEY")))
 
(define coding-caller
  (make-openai-caller #:url "http://localhost:8000"
    #:extra-body (hasheq 'chat_template_kwargs
                         (hasheq 'enable_thinking #f))))
 
(define plan-step
  (make-agent-ashlar planning-caller
    #:produces 'plan
    #:model "claude-sonnet-4-6-20250514"
    ...))
 
(define code-step
  (make-agent-ashlar coding-caller
    #:produces 'implementation
    #:model "Qwen/Qwen3.5-35B-A3B"
    ...))

#:model on make-agent-ashlar overrides default-model for that ashlar. Use it to pin a specific ashlar to a specific model while the rest of the ashlar uses the default.

4.10.8 Troubleshooting🔗ℹ

  • "reserved key X cannot be overridden". You tried to set a reserved field through #:extra-body. Use the corresponding Stone parameter instead (#:model default-model, the agent ashlar’s #:response-format, the middleware list).

  • Anthropic caller accepts the threshold but no token crossings fire. Streaming isn’t implemented for the Anthropic caller yet — see Provider constraints.

  • Tools attached, schema set, no tool call ever fires on vLLM. The schema decoder is masking tool-use tokens. Use the The ashlar-pair pattern.

  • Request times out on Qwen3.5 schema-enforced calls. Thinking mode is on by default. Add the chat_template_kwargs extra-body shown above.

4.10.9 See also🔗ℹ

4.11 Use the TUI🔗ℹ

The Stone terminal UI is the run’s interactive supervisor. It displays ashlar progress across three panels — agents, DAG, and conversation — and routes human interaction through the run’s threshold: it subscribes once, answers ask-human prompts from the input line, approves tool-call gates through a permission modal, and cancels an in-flight turn cooperatively. The session is durable, so a relaunch resumes it from the journal. This guide shows how to launch an ashlar under the TUI, navigate the panels, and drive those interactions. For the model behind human interaction see Ask Human; for the durable session see Run a durable, resumable session.

4.11.1 Prerequisites🔗ℹ

  • Stone installed and visible to raco.

  • A terminal that supports ANSI escape sequences, the alternate screen buffer, and mouse reporting. Any modern emulator will do.

  • An agent builder: a .stone/settings.rkt that provides a zero-argument build-agent (the run supplies the threshold, so the builder takes no arguments), or a url flag so a default builder is synthesized for you.

4.11.2 1. Launch the TUI🔗ℹ

The packaged entry point is raco stone. It loads .stone/settings.rkt (walking up from the current directory to $HOME), merges any flags you pass, builds one threshold, binds it for the run (the agent reads current-threshold when it emits), allocates a session journal under .stone/sessions/, and launches the TUI over it.

raco stone --url http://localhost:8000 --model llama-3.1-70b

Flag

  

Purpose

url <url>

  

LLM API endpoint URL.

model <model>

  

Model identifier. Also updates default-model.

continue [id]

  

Resume a recorded session (most-recent when no id).

url and model override values from .stone/settings.rkt. If neither a config file nor url produces an agent, the CLI prints a hint and exits without launching the TUI. List past sessions with raco stone sessions.

For an ashlar you assemble yourself, call run-tui directly. Build the threshold and hand it to run-tui along with the session journal path; the agent itself takes no threshold — run-tui binds the threshold for the run and the agent reads current-threshold when it emits:

(require stone/tui-main stone/threshold)
(define th (make-threshold))
(define my-agent (build-my-ashlar))   ; no threshold at construction
(run-tui my-agent
         #:record "session.jsonl"
         #:threshold th)

run-tui attaches the threshold, opens the record, subscribes once, and runs (run-ashlar my-agent #:record r #:loop #t) on a background thread — a durable, resumable session that loops until a session-terminal halt (you quit).

4.11.3 2. Panel layout🔗ℹ

+-------------------+-----------------------------+

| Agents            | Conversation                |

|                   |                             |

+-------------------+                             |

| DAG               |                             |

|                   |                             |

+-------------------+-----------------------------+

> input line

[i]nput  [b]ranch  [m]erge  [d]etail  [/]search  [?]help

  • Agents (top-left) — one line per running agent with a status icon ( active, waiting, resolved, halted) and the current turn number.

  • DAG (bottom-left) — structural nodes along the current head path, with markers for branches you can drill into. Selecting a branch and pressing Enter focuses that sub-history in the conversation panel; Esc drills back out.

  • Conversation (right) — the message history of the current view, rendered with a role prefix (>> user, << assistant, .. agent, ## tool). Streaming LLM output appears inline as tokens arrive. On a resumed session this panel opens pre-filled with the prior session’s history as scrollback (seeded from the journal).

  • Input line> prompt, with a block cursor when active.

The focused panel is drawn with double-line borders; unfocused panels use single lines.

4.11.4 3. Keybindings🔗ℹ

The TUI has three modes: normal, input, and modal.

4.11.4.1 Global🔗ℹ

Key

  

Action

Ctrl+C

  

Cooperatively cancel the in-flight turn. The partial turn is dropped; the session survives and re-prompts.

Ctrl+\\

  

Decline the pending ask-human question; the run stays alive.

4.11.4.2 Normal mode🔗ℹ

Key

  

Action

q

  

Quit the session and restore the terminal. Ends the run (see 5. Cancel, decline, and quit).

Tab

  

Cycle focus: agents → DAG → conversation → agents.

1

  

Focus agents.

2

  

Focus DAG.

3

  

Focus conversation.

j / k

  

Move selection or scroll depending on panel.

i

  

Enter input mode.

Enter

  

In DAG: drill into the selected branch.

Esc

  

In DAG with a non-empty stack: drill out one level.

+ / -

  

In conversation: expand / collapse thinking blocks.

4.11.4.3 Input mode🔗ℹ

Key

  

Action

Enter

  

Submit. Answers a pending ask-human question when one is waiting; otherwise steers the live run (injects a user turn).

Esc

  

Cancel input mode and clear the buffer.

Backspace

  

Delete the character before the cursor.

Left / Right / Home / End

  

Move the cursor.

4.11.4.4 Modal mode🔗ℹ

The permission modal opens when an agent poses a tool-call gate. y approves the call ('allow); n or Esc declines it ('deny) — a gate is never left blocked.

4.11.5 4. Answer an ask-human prompt🔗ℹ

When a make-ask-human ashlar fires, its question crosses the threshold as an 'ask. The TUI appends it as an assistant node to the conversation panel (prefixed with [ask-human]), activates input mode, and remembers the ask’s routing id.

Type your answer and press Enter: the TUI calls threshold-answer! with that id, and the ashlar unblocks and produces its 'human-response node. When no ask is pending, Enter instead threshold-steer!s — it injects your text as a user turn into the live run.

There is no channel wiring. The TUI subscribes to the run’s single threshold, so every ask-human ashlar anywhere in the topology is visible automatically.

4.11.6 5. Cancel, decline, and quit🔗ℹ

Three distinct interruptions, three keys:

Key

  

Scope

  

Effect

Ctrl+C

  

The in-flight turn

  

Cooperative cancel. Sets the threshold’s cancel flag; the streaming loop drops the partial turn between chunks. No thread is killed — the session seals the interrupted iteration and re-prompts, so a follow-up Enter continues the same record.

Ctrl+\\

  

The pending question

  

Declines the ask with 'declined. The ashlar produces a 'declined halt; the rest of the run continues.

q

  

The whole session

  

Cancels the pending input ask with 'user-quit, which yields a session-terminal halt so the #:loop exits cleanly; the TUI then restores the terminal.

Reach for Ctrl+C when the current turn is going nowhere but you want to keep the session; Ctrl+\\ to refuse one prompt; q to end the run.

4.11.7 6. Resume a session🔗ℹ

Because the run is durable, quitting is not losing. Relaunch with raco stone continue (most-recent session) or raco stone continue <id>; the journal replays as scrollback and the run picks up where it stopped, provided the topology is unchanged. Doing it by hand is the same run-tui call over the same #:record path. See Run a durable, resumable session.

4.11.8 Troubleshooting🔗ℹ

  • Terminal corrupted after a crash. Run reset, or stty sane; tput cnorm; tput rmcup, to restore cursor visibility and leave the alternate screen buffer.

  • Garbled box-drawing or status glyphs. The TUI uses Unicode box characters and small glyphs. Switch to a UTF-8-capable terminal.

  • Ask-human prompt never appears. No threshold was bound for the run, or one was bound that the TUI isn’t subscribed to. Pass a threshold to run-tui (it binds current-threshold for the run and subscribes to it); the agent emits to whatever current-threshold is bound at emission time.

  • Ashlar appears frozen. The TUI updates on crossings. If none are flowing — the agent is blocked on a long non-streaming LLM call — no panels redraw until the call returns. Enable the trace receiver from Trace a run to see whether the agent is actually working.

  • q does nothing. You’re in input mode. Press Esc first, then q.

4.11.9 See also🔗ℹ

4.12 Test ashlars that use tools🔗ℹ

This guide shows how to test an ashlar that calls tools — ask_user, a file reader, a shell runner — without either mocking the whole ashlar or shipping prose where a tool call belonged. The test harness is stone/test. For the conceptual model see The test harness.

The motivating failure mode: you wire an agent ashlar with an ask_user tool, run it against a real LLM, and the model writes the question as prose instead of emitting a tool call. The ashlar doesn’t pause for input; it continues with an assistant turn that looks like a question but never reaches the frontend. A unit test that asserts check-tool-called? catches this.

4.12.1 Prerequisites🔗ℹ

4.12.2 1. Live test: does this ashlar actually call ask_user?🔗ℹ

A live test exercises the real LLM. It catches behavior drift — the model changed its mind about whether a sentence warrants a tool call — that a mock test can’t see. Stub only the tool’s side effects; leave the LLM wired up.

(require rackunit stone stone/llm-client stone/llm-ashlar
         stone/test stone/tools)
 
(define caller
  (make-openai-caller #:url "http://localhost:8000"))
 
(define ask-user-tool
  (make-tool 'ask_user
    #:schema (hasheq 'name "ask_user"
                     'input_schema
                     (hasheq 'type "object"
                             'properties (hasheq 'question (hasheq 'type "string"))
                             'required '("question")))
    #:handler (lambda (args) (values "mocked answer" (hasheq)))))
 
(define discover
  (make-agent-ashlar caller
    #:produces 'project-config
    #:middleware (list ask-user-tool)
    #:context (context (system "You discover the project's language.")
                       (user "Find out what language this project uses."))
    #:max-turns 5))
 
(test-case "discover asks the user when the project is empty"
  (define stubbed
    (ashlar-with-tool-stub discover 'ask_user
      (stub-answer "racket")))
  (with-live-harness #:caller caller #:timeout 30
    (stubbed (make-dag))
    (check-tool-called? 'ask_user
      "expected the model to emit an ask_user tool call, not prose")))

What each piece does:

4.12.3 2. Mock test: ashlar wiring without network calls🔗ℹ

A mock test scripts the LLM’s responses. It catches wiring bugs — the ashlar forgot to declare the tool, the decide function never loops — and runs in milliseconds.

(require rackunit stone stone/llm-ashlar stone/llm-types
         stone/test stone/tools)
 
(define ask-user-tool
  (make-tool 'ask_user
    #:schema (hasheq 'name "ask_user"
                     'input_schema
                     (hasheq 'type "object"
                             'properties (hasheq 'question (hasheq 'type "string"))
                             'required '("question")))
    #:handler (lambda (args) (values "racket" (hasheq)))))
 
(define (loop-if-recommended ctx recs)
  (if (ormap (lambda (r) (eq? (recommendation-type r) 'loop)) recs)
      (recommendation 'loop 'mock (hash))
      (recommendation 'continue 'mock (hash))))
 
(test-case "discover asks, then emits project-config"
  (with-mock-harness
      #:responses (list (llm-tool-call "ask_user" #:question "language?")
                        (llm-text "done"))
    (define discover
      (make-agent-ashlar (harness-current-caller)
        #:produces 'project-config
        #:middleware (list ask-user-tool)
        #:decide loop-if-recommended
        #:context (context (system "s") (user "u"))
        #:max-turns 5))
    (discover (make-dag))
    (check-tool-called? 'ask_user)
    (check-tool-call-count 'ask_user 1)))

Key moves:

If you already have a caller you want to drive manually, pass it via #:caller instead:

(with-mock-harness #:caller my-caller
  (my-ashlar (make-dag))
  (check-tool-called? 'ask_user))

4.12.4 3. Catch accidentally-unstubbed tools with #:strict-tools?🔗ℹ

When a test forgets to ashlar-with-tool-stub a tool, the real handler runs. For an ask_user tool wired to a live frontend, that means the test blocks on stdin. #:strict-tools? turns this into a loud failure at the end of the harness block.

(test-case "strict-tools catches an unstubbed ask_user"
  (check-exn exn:fail?
    (lambda ()
      (with-mock-harness
          #:responses (list (llm-tool-call "ask_user" #:question "?")
                            (llm-text "done"))
          #:strict-tools? #t
        (define s (make-agent-ashlar (harness-current-caller)
                    #:produces 'x
                    #:middleware (list ask-user-tool) ; NOT stubbed!
                    #:decide loop-if-recommended
                    #:context (context (system "s") (user "u"))
                    #:max-turns 3))
        (s (make-dag))))))

Turn #:strict-tools? #t on by default for new tests. Turn it off only when you deliberately want the real handler to run.

4.12.5 4. Bound test runtime with #:timeout🔗ℹ

Live tests can hang. #:timeout runs the body on a thread, syncs on thread-dead-evt with a deadline, and breaks the thread if the deadline expires.

(with-live-harness #:caller caller #:timeout 30
  (stubbed (make-dag))
  (check-tool-called? 'ask_user))

Timeout is in seconds. with-mock-harness doesn’t take #:timeout — mock tests run synchronously.

4.12.6 5. Reference: which assertion should I use?🔗ℹ

Assertion

  

Use when

check-tool-called?

  

At least one call of this name happened.

check-tool-not-called?

  

No call of this name happened.

check-tool-call-count

  

Exactly n calls happened.

(tool-calls)

  

Full list of records for custom asserts.

(tool-calls-by-name)

  

Records for one tool.

(tool-call-count)

  

Count for one tool.

Each record is an immutable hasheq with keys 'name, 'input, 'result-text, and 'result-meta. Use (hash-ref r 'input) in a custom assertion when you care about the arguments:

(define calls (tool-calls-by-name 'ask_user))
(check-equal? (hash-ref (hash-ref (first calls) 'input) 'question)
              "What language does the project use?")

4.12.7 6. Stub helpers🔗ℹ

  • stub-answer s — returns a handler that always produces the string s with meta (hasheq 'stub #t).

  • stub-fn f — returns f unchanged. Reach for it when you want a handler that inspects args or records state itself.

4.12.8 7. Response builders🔗ℹ

  • llm-text s — an llm-response with text s and no tool calls.

  • llm-tool-call name #:id id #:question q #:input h — a response whose only content is a single tool call. #:question is a shortcut that wraps the value in (hasheq 'question q).

  • llm-multi text-or-response call ... — combine prose with one or more tool calls in the same response.

4.12.9 8. When to use live vs. mock🔗ℹ

Live tests catch behavior drift in prompts. Mock tests catch wiring bugs deterministically and fast. Write both: a single live test per ashlar to pin behavior, and a suite of mock tests for the wiring.

4.12.10 Troubleshooting🔗ℹ

  • check-tool-called? fails but you can see the tool fire in logs. The recorder drains stone-logger at level 'info. If you muted the logger or swapped it, the events never reach the recorder.

  • with-live-harness times out but the ashlar seemed to finish. The body ran on a thread and threw — the harness syncs on thread-dead-evt, which fires on either clean exit or exception. Remove the timeout briefly to see the real error.

  • ashlar-with-tool-stub errors with "ashlar has no tool named X". The tool name isn’t in the ashlar’s middleware, and there’s no child subtree carrying it. Double-check the tool symbol matches (middleware-name ...).

  • with-mock-harness raises "ran out of scripted responses". The agent made more LLM calls than you supplied responses for. Either add more or tighten #:decide.

4.12.11 See also🔗ℹ

4.13 Use the ashlar skills🔗ℹ

Stone ships two Claude Code skills that work as a pair: scope-ashlar scopes a unit of work into a build-ready spec tree, and build-ashlar translates that tree into a runnable Stone scaffold. They install together with one raco command. This guide shows how to install them, the hand-off between them, what gets written to disk, and when to reach for something else.

4.13.1 Prerequisites🔗ℹ

  • Stone installed and visible to raco.

  • Claude Code installed, with skills enabled.

  • An empty directory if you’re starting greenfield, or an existing Stone project you want to extend.

4.13.2 1. Install the skills🔗ℹ

From any directory, run:

raco stone install-skill

This copies both bundled skills into ~/.claude/skills/scope-ashlar and ~/.claude/skills/build-ashlar. build-ashlar also receives a full snapshot of the Stone scribblings (reference, explanation, how-to, tutorials) under ~/.claude/skills/build-ashlar/scribblings/, so it can look up primitive signatures from a predictable local path whether you installed Stone from source or via raco pkg install stone. scope-ashlar ships no scribblings snapshot — it writes no Racket, so it doesn’t need one. Claude Code picks the skills up the next time you start a session.

When you upgrade Stone, re-run install-skill to refresh the scribblings snapshot — the bare command (no force) refreshes build-ashlar’s scribblings only and leaves both skill bodies unchanged, so it’s safe to run any time:

raco stone install-skill

To overwrite the skill bodies themselves (e.g. after the skill sources change upstream), pass force:

raco stone install-skill --force

Pass help to print usage; unknown flags are rejected with an error.

4.13.3 2. The two-skill flow🔗ℹ

The skills divide the work along a single line: scope-ashlar decides what a unit is; build-ashlar makes it run.

  1. Scope first. scope-ashlar triggers on phrases like "scope an ashlar", "design this in Stone", or "break this work into Stone pieces". It runs a recursive interview — for each ashlar: what it accomplishes, what its adversary looks for, and what it needs to operate — and writes a build-ready spec tree under ./.ashlars/<name>/. It writes no Racket. When every ashlar is fully scoped it prints "Call build-ashlar next."

  2. Build second. build-ashlar triggers on phrases like "build this ashlar", "implement the scope", or "scaffold Racket from .ashlars". It reads the .ashlars/<name>/ tree as the source of truth, asks which provider you’re targeting, and emits the Racket scaffold — validating, testing, and surfacing any implementation gaps as it goes. If you ask it to build something that was never scoped, it hands you back to scope-ashlar.

The disambiguator is the artifact: scope-ashlar produces .ashlars/<name>/; build-ashlar requires it. Defining a unit is scope-first; implementing one is build-first.

4.13.4 3. What scope-ashlar writes🔗ℹ

A directory tree under ./.ashlars/<name>/, one directory per ashlar, with:

  • spec.org — what the ashlar accomplishes.

  • materials.org — what it needs (layer, inputs, system prompt or operation, tools, model, expected terminal node), written in full.

  • harness.org — the spec for the ashlar that verifies this one.

  • topology.stone — composite ashlars only: the runtime DAG in literal Stone syntax (~>, ashlar-loop, ashlar-match, ashlar-map / ashlar-parallel / ashlar-reduce).

4.13.5 4. What build-ashlar generates🔗ℹ

A multi-file Stone scaffold, not a single self-contained .rkt blob:

  • ashlars.rkt — one (make-ashlar ...), (make-agent-ashlar ...), or (make-ask-human ...) per scoped ashlar, plus any adversaries.

  • topology.rkt — the ~> / ashlar-loop / ashlar-match form translated from topology.stone, exporting a single ashlar binding.

  • main.rkt — the entry point: caller construction (for the provider you chose), the (default-model ...) call, and a top-level run-ashlar call (under a record, with a supervisor, when any ashlar is human-judgment layer).

  • tests.rkt — tests derived from the scoped harnesses: contract tests for property-test harnesses, good/bad adversary pairs for rubric-judge harnesses, ask smoke tests for human-review harnesses.

  • middleware.rkt — only generated if a scoped ashlar uses a custom (make-tool ...); stays absent otherwise.

The four-file layout (five with middleware) is the default deliverable, not a deluxe option. It exists so you can iterate on prompts, topology, and tests independently without re-reading the whole ashlar.

4.13.6 5. When NOT to use these skills🔗ℹ

  • You’re debugging an existing ashlar. See Debug an ashlar and Trace a run.

  • You’re extending a single ashlar’s body — adding one more match arm, tightening a prompt, swapping a model. That’s an edit, not a design; open the file and make the change.

  • You’re making a small topology edit — wrapping one step in ashlar-loop, inserting a guard, swapping ~> for a match. Use Validate a topology to check the result.

  • You have a conceptual question about Stone. Open the scribblings: raco docs stone.

4.13.7 See also🔗ℹ

  • Ashlars — the conceptual model the skills scaffold against.

  • Validate a topology — what build-ashlar runs to confirm couplings.

  • Debug an ashlar — what to do once the scaffold is running and something goes wrong.

4.14 Coming from LangChain / LlamaIndex / bare SDK🔗ℹ

If you’ve written LLM pipelines before in Python, Stone looks unfamiliar twice over: the composition model differs from what chain-of-calls frameworks do, and the host language is Racket. This guide translates the mental models. It doesn’t teach Stone from scratch — for that, start with Getting Started. It’s for the case where you already know what you want to build and need the Stone vocabulary for the pieces you’re used to thinking in.

4.14.1 If you’re coming from LangChain / LangGraph🔗ℹ

The closest mental mapping is LangGraph. Both frameworks separate topology from execution; both make coordination a first-class object. The differences are where it gets interesting.

4.14.1.1 State🔗ℹ

LangGraph

  

Stone

A graph state dict passed between nodes, mutable per-edge

  

An append-only typed DAG read by every ashlar, mutated only by appending

Reducers on state fields merge updates

  

No merges — every new node has an identity; history is intact

State is effectively flat (dict of fields)

  

State is a graph of typed nodes; order and lineage are visible

In LangGraph, a node writes to the graph state by returning a partial dict that the framework merges. In Stone, an ashlar writes a single new typed node. The previous state isn’t overwritten — it’s still in the DAG, and subsequent ashlars can walk back to it via dag-nearest-ancestor or dag-query-all.

This matters when you need to retry, branch, or see what the ashlar has actually accumulated. LangGraph shows you the current state dict; Stone shows you the entire history.

4.14.1.2 Edges and routing🔗ℹ

LangGraph

  

Stone

add_edge / add_conditional_edges build the graph

  

~> sequences ashlars; ashlar-match branches on DAG content

Conditional routing via a predicate function

  

ashlar-match with an extractor/lens dispatches on the latest node

START and END nodes are explicit

  

Pipeline is just an ashlar; run-ashlar applies it to (make-dag)

LangGraph’s add_conditional_edges is Stone’s ashlar-match. Both dispatch based on runtime data; the difference is that ashlar-match’s branches are collected statically, so the validator can walk them before the ashlar runs.

4.14.1.3 Chains🔗ℹ

A LangChain chain (prompt | llm | parser) is usually one make-agent-ashlar in Stone — the parser lives inside the agent ashlar’s response-format machinery. A multi-step chain becomes a ~> of ashlars. A chain with memory becomes an ashlar that reads from the DAG; there is no separate memory object.

4.14.1.4 Agents🔗ℹ

LangChain agents and LangGraph ReAct agents map onto make-agent-ashlar with a middleware list. Tools become make-tool middlewares. The agent’s decide function picks between 'loop (run another turn) and 'continue (exit with the current draft).

The adversary + heal-with pattern has no direct analog in LangChain. It’s closest to a ReAct agent with a validation step, but the rejection feedback enters the agent’s conversation rather than being injected as a new prompt. See Agents and Tools.

4.14.1.5 Streaming🔗ℹ

LangChain/LangGraph streaming works out of the box in Python. Stone’s OpenAI-compatible caller streams by default; the Anthropic caller doesn’t yet. If streaming observability matters, stick to OpenAI-compat or wait for the Anthropic streaming adapter. See Provider constraints.

4.14.2 If you’re coming from LlamaIndex🔗ℹ

LlamaIndex focuses on retrieval and query engines; its pipeline abstractions (QueryPipeline, Workflow) are adjacent to Stone’s territory.

LlamaIndex

  

Stone

QueryPipeline with modules linked by input/output keys

  

~> chain of ashlars coupled by node types

Workflow with event-driven step dispatch

  

ashlar-match / ashlar-loop dispatching on DAG content

Built-in vector stores, retrievers, rerankers

  

Bring your own — retrieval is just another ashlar

Stone doesn’t ship RAG primitives. If your work is "index a corpus, retrieve, rerank, answer," LlamaIndex probably remains the faster path and you can call it from a Stone ashlar when you need pipeline-level composition.

Where Stone helps over LlamaIndex Workflow: the DAG is visible and replayable. Every intermediate result is a typed node with a stable identity; you can print it, compare two runs, or feed it into a downstream ashlar without extra serialization plumbing.

4.14.3 If you’re coming from a bare SDK🔗ℹ

You have a script that calls the Anthropic or OpenAI SDK in a loop. It works. Why would you reach for Stone?

You wouldn’t — until the script grows any of:

  • A loop with a termination condition that depends on structured output.

  • A human-in-the-loop approval step that pauses execution.

  • Fan-out over a list of items followed by a reducer.

  • Multi-turn tool use with deterministic validation after each turn.

  • Replay of a failed run from the point of failure.

  • A typed trace of what the model saw and decided at each step.

Each of these is a state machine, and hand-rolling them around SDK calls gets expensive fast. Stone’s tax is learning the framework; your current tax is the state machines you already maintain. The break-even is roughly "do I want to do this pattern more than once in more than one place."

4.14.3.1 Mapping SDK calls to Stone🔗ℹ

A single SDK call becomes one make-agent-ashlar with #:max-turns 1 and #:middleware '() — the single-shot pattern. The response text is available via node-text on the produced node.

A multi-turn tool-using agent becomes make-agent-ashlar with tool middlewares and a decide function. The framework handles the conversation list, tool schema injection, tool dispatch, and the turn loop. You don’t write that loop.

A retry wrapper becomes a ashlar-loop with a predicate. A fallback becomes a ashlar-match branching on the previous ashlar’s result.

4.14.4 Racket-specific things LangChain doesn’t have🔗ℹ

A few Racket-native idioms Stone uses that don’t have Python analogs:

  • Parameters (default-model, current-trace-id) — dynamic-scope values that propagate through every call inside a parameterize body. Closest Python analog: contextvars.ContextVar.

  • The threshold — the single opening a supervisor observes and answers a run through. An ask-human ashlar poses a question on it and blocks; a supervisor (the TUI, a test) subscribes and answers. Closest Python analog: a subscribed asyncio.Queue of events plus a reply handle.

  • Keyword arguments — Stone uses #:like-this keyword args everywhere. They’re required to appear after positional arguments and can appear in any order.

See Reading Racket for the minimum you need to parse Stone code.

4.14.5 Why you’d choose Stone🔗ℹ

Three reasons it’s worth the switch:

  • Compose-time validation. Every topology can be walked before it runs. Missing producer, typo’d query, lens that doesn’t match the schema — all caught in milliseconds without an LLM call.

  • Immutable, content-addressed history. Every run produces a DAG you can print, diff, replay, or feed into the next ashlar. When a production ashlar misbehaves, you open the DAG and see what happened instead of hunting through logs.

  • One layer. A six-turn tool-using agent and a three-line parser compose with the same primitive. You’re not juggling two frameworks (scaffold + agent runtime) and their interactions.

Three reasons you wouldn’t:

  • Ecosystem. LangChain/LlamaIndex have more integrations, more vector stores, more provider adapters. If your work is primarily gluing existing libraries, Python wins.

  • Streaming on Anthropic. Not yet implemented. If streaming to the Anthropic API matters today, use the SDK or wait.

  • You don’t need the invariants. One-off scripts rarely benefit from compose-time validation. Reach for the SDK.

4.14.6 See also🔗ℹ