Unbounded: References
Sources on economics, wealth, taxation, and progress
Sources on economics, wealth, taxation, and progress
How pressure can lead to wealth tax legislation.
<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> vim-slime </h1> <p> Mar 26, 2012 </p> </div> </div> <div id="post_content"> <html><body><p>Today I found the awesomeness that is <a href="https://github.com/jpalardy/vim-slime">vim-slime</a>, it's been an exciting day for me. <a href="http://common-lisp.net/project/slime/">Slime</a> is the "The Superior Lisp Interaction Mode for Emacs", I can almost hear the emacs crowd laughing.</p> <p>For those that use vim and haven't used Slime, vim-slime or <a href="https://github.com/vim-scripts/VimClojure">something similar</a>, this is why it's awesome:</p> <p><strong>Text can be sent from any process to the stdin of a <a href="http://www.gnu.org/software/screen/">gnu screen</a> or <a href="http://tmux.sourceforge.net/">tmux</a> session. The process in this case is vim and the screen/tmux session is a terminal</strong>.</p> <p>Screen is a <a href="/journal/oh-screen-where-have-you-been">really neat</a> terminal multiplexer (you can run multiple terminals in a terminal window). The multiplexed shell processes are children of the screen process, which itself is not a child of the terminal window process. This means a screen process and its child processes keep running if you close the terminal window. Later you can re-connect to it, this is what makes vim-slime possible.</p> <p>Here is an screen shot, on the left is me in gVim writing some awful Clojure <a href="#footnote-1">[1]</a>. On the right is a screen buffer in which I started a Clojure REPL. When I want to try run some code I can send any vim text selection to the REPL in a keystroke (or two).</p> <p><img alt="vim slime screenshot" src="screenshot.jpg"/></p> <p>It doesn't have to be a Clojure REPL either, we can send anything to a screen shell. We could run git commands, find, grep, sed, etc. Like with the Clojure REPL we can even interact with any terminal programs that use STDIN.</p> <p>This concept can be taken even further, You can even connect to a tmux session over SSH and share a terminal or a <a href="http://remotepairprogramming.com/remote-pair-programming-with-tmux-and-vim-the">terminal program like vim to do remote pairing</a>!</p> <p>Hopefully remote pairing is the topic of my next post as there are a couple geographically distant people I know who are keen to do some pair hacking. I stand to learn a lot!</p> <p><a name="footnote-1">[1]</a> I learnt almost everything I know about Lisp from <a href="http://www.ccs.neu.edu/home/matthias/BTLS/">The Little Schemer</a>. Great book.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p> </p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p> </p> </div> </body> </html>
<!DOCTYPE html> <html> <head> <title>Tarn Barford</title> <meta charset="utf-8"/> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link href="/style.css" media="screen" rel="stylesheet" type="text/css" /> <link rel="alternate" type="application/atom+xml" title="Journals of Tarn Barford" href="/atom" /> <link href="/highlight.css" media="screen" rel="stylesheet" type="text/css" /> <link href="/highlight-console.css" media="screen" rel="stylesheet" type="text/css" /> <style> #swipe-canvas { position: relative; width: 900px; height: 300px; } #swipe-results { font-size: 30px; padding-left: 50px; padding-left: 50px; } #swipe-results ul { margin: 0px; padding: 0px; } #swipe-results li { float: left; background-color: #DDDDDD; list-style-type: none; padding: 10px; margin: 5px; border-radius: 5px; } #swipe { position: relative; } #swipe-loading { position: absolute; height: 50px; width: 300px; top: 85px; left: 300px; background-color: darkgray; border-radius: 10px; text-align: center; padding-top: 20px; border: black; border-width: 5px; } </style> </head> <body> <div id="container"> <div id="header"> <div id="header"> <p>From the <a href="/journal">Journals</a> of <a href="/">Tarn Barford</a></p> <h1> Swipe Keyboard </h1> <p> Apr 06, 2014 </p> </div> </div> <div id="post_content"> <html><body><p>When I first tried a <a href="http://www.swype.com/">Swype</a> keyboard I was impressed how effective it was. Even though I don't use the feature on my phone I was interested in how it could be built, so I <a href="https://github.com/tarnacious/swipe-keyboard">implemented this otherwise useless swipe-able keyboard</a> below. It probably doesn't work on mobile devices, but works on modern browsers with mouse pointers (although I've only really tried Chrome and Firefox).</p> <div id="swipe"> <canvas height="300px" id="swipe-canvas" width="900px"></canvas> <div id="swipe-results"></div> <div style="clear: both"></div> <h2 id="swipe-loading">Loading<noscript>Javascript is Required</noscript></h2> </div> <p>I initially tried to solve this using the technique Peter Norvig famously uses in his <a href="http://norvig.com/spell-correct.html]">spell checker</a>. He takes a sequence of characters and generates a set of word candidates by adding, removing and swapping characters in the original sequence, the generated candidates are removed if they are not found a dictionary. This can work but to be effective too many combinations need to be generated.</p> <p>If the dictionary is indexed into a <a href="http://en.wikipedia.org/wiki/Trie">trie</a> the number of combinations generated can be reduced significantly by traversing the trie and only generating valid letter combinations. This is a pretty bare implementation of that, it requires: </p> <ul> <li>The first and last characters of the initial sequence are used </li> <li>Intermediate characters in the initial sequence can be repeated or discarded </li> <li>No characters are added or swapped</li> </ul> <p>Basically, if you swipe through all the characters in a word in order, then the word will be found if it is in the index regardless how many characters are swiped in between. It is surprisingly quick and effective.</p> <p>This implementation uses <a href="https://raw.github.com/first20hours/google-10000-english/master/google-10000-english.txt">these 10000 words</a>, I intended to use digital books but never got around to it as these words demonstrate the concept well enough.</p> <p>This is the first thing I've written in <a href="https://github.com/clojure/clojurescript">ClojureScript</a> or <a href="https://github.com/clojure/clojurescript">Clojure</a>, so my code my vary from non-idiomatic to shamblolic. I initially used a <a href="http://clojuredocs.org/clojure_core/clojure.zip/zipper">zipper</a> to build the trie with immutable data structures, but found the indexing took to long with my zipper implementation so I <a href="https://github.com/tarnacious/swipe-keyboard/commit/6edd7b26e78121fbe8586b3f0ef54ca8277d9e32">switched to using native Javascript maps</a>.</p> <p>I found that <a href="https://github.com/clojure/core.async">core.async</a> library is really awesome, the <a href="http://docs.closure-library.googlecode.com/git/index.html">Google closure library</a> and <a href="https://developers.google.com/closure/compiler/">compiler</a> integration with <a href="http://leiningen.org/">Leiningen</a> the <a href="https://github.com/emezeske/lein-cljsbuild">cljsbuild plug-in</a> to be impressive. My main pains were the slow JVM start-up time, the advanced closure compiler build of the web worker script fails silently when run (but the main script works fine when compiled with the advanced compiler), and at times I felt some compile time type checking would be nice.</p> <p>I would like to extend this experiment to index the word occurrence counts and proceeding word counts in original text and rank the found words as most likely. Support casing, umlauts, special characters, spelling correction and compound words in the indexing and lookup. I think a live lookup while swiping would also be possible.</p> <p>Overall this was fun, turned out OK I think, and was a great learning experience.</p></body></html> </div> <div id="comments"> </div> </div> <div id="footer"> <p> </p> <p>Questions, comments, suggestions? Email me, <a href="mailto:tarn@tarnbarford.net">tarn@tarnbarford.net</a> (<a href="/pgp.txt">public key</a>)</p> <p> </p> </div> <script src="swipe.js" type="text/javascript"></script> </body> </html>
What would happen to Stuttgart’s shops if the Milaneo shopping centre near the main station closed? What if the same floor space stood in Zuffenhausen instead? A simulation of the whole city, fitted to published data, answers with its uncertainty attached. If the Milaneo closed, about half of the 93 M€ a year that residents spend there would move to other shops in Mitte. A centre of the same size in Zuffenhausen would capture 59 % of that money, somewhere between 27 and 97 M€. This article describes the research demonstrator behind those numbers, and you can open the explorer and follow along. The model is a prototype, and its numbers are results under stated assumptions, not forecasts.
The explorer with the Milaneo's floor space moved to Zuffenhausen. Green districts and rings gain resident spending, red ones lose it. Each number at the bottom comes with a 90 % band over the fitted parameters.
A city is a good test for a simulation because everyone has intuitions about it and some of those intuitions are measured. Stuttgart publishes how many people live in every 100 m square, how much floor space its shops have, and how much they sell by district and by kind of goods. A model that claims to explain the city’s retail has to reproduce those numbers, and where it cannot, the gap is information.
The demonstrator is built so that three things can be checked. Its inputs are measured, and each names its source. Its parameters are inferred from published figures, with the uncertainty those figures leave. A policy question is an explicit intervention on the model, evaluated with the same random draws as the baseline. The next three sections take them in turn.
The simulation builds a synthetic Stuttgart from published data. Residents are drawn cell by cell from the Zensus 2022 grid and in-commuters from the employment agency’s commuter statistics. The 3,637 shops come from Overture Maps and OpenStreetMap, with floor areas taken from building footprints and scaled to the city’s retail survey. The model page lists every input with its source.
Each person then lives a weekday from an activity diary that says when they leave home, go to work, shop or eat. German diary microdata is available only through a research data centre, so the diaries come from the Statistics Canada Time Use Survey 2022, reweighted to Stuttgart’s measured trip rate. That transfer is the model’s largest assumption.
When a diary says shop, the person picks a shop. The choice follows Huff’s gravity model, in which a shop’s pull grows with its floor area and falls with distance:
Here j is a shop, c the 100 m cell the person is in, Aj the shop’s floor area and dcj the distance between them. The exponent α sets how much size matters, β how quickly distance puts people off, and d0 the radius within which distance hardly matters. The probabilities are normalised over all 3,637 shops. Nobody in this model optimises over the city. People weigh size against distance, which is bounded rationality in Herbert Simon’s sense, and the data have to decide how.
Shopping comes in three demand classes, because one kernel cannot fit Stuttgart: a kernel flat enough to fill the centre with clothing turnover makes grocery trips implausibly long. Food and daily needs, clothing and shoes, and long-lived goods such as furniture each get their own α, β and d0, their own share of every household’s purchasing power, and their own floor area per shop. That makes nine numbers.
Each shopping trip is decided once: its class, whether the purchase leaves the city, and the shop. The trips drawn on the map, the visits counted at each shop and the money those visits carry all come from that one decision, so the explorer shows a single simulated day rather than separate layers that merely agree on average.
The city’s retail concept publishes turnover for each of 23 districts in each of the three classes, 69 observations in all. The model predicts the same 69 numbers by sending every resident’s spending through the choice kernel and summing where it lands. The prediction is an exact expectation rather than a sampled day, so it carries no Monte Carlo noise, and a likelihood compares it with the published values on a log scale.
Inference asks which values of the nine numbers make the published turnover plausible. The answer is a posterior distribution, a set of possible cities rather than one best fit, which the Bayesian inference article illustrates. Here it is computed with Spindel, the probabilistic programming runtime of the replikativ stack, as two independent runs of 48 Metropolis–Hastings chains, each of which runs the whole-city simulator at every step. The likelihood is evaluated on a GPU in about half a second per step, so a chain can take 200 steps in an hour. Started from different random points, the two runs end in the same distribution for every parameter. That checks where the chains end rather than how well each one explored, so it is a necessary test of convergence, not a proof.
The figure shows what district totals can and cannot decide. They fix how strongly floor area attracts spending on long-lived goods. They leave the distance decay close to where the prior put it, because a steeper decay with a wider flat zone near home produces much the same district totals as a shallower one.
That points to what would sharpen the model. Measured shopping trip distances, such as those in the Mobilität in Deutschland survey, would constrain the distance decay directly. Visit counts per shop, anonymised card spending or pedestrian counts at a few dozen points would each separate kernels that district totals cannot. A simulation with an explicit likelihood can say which measurement would be worth collecting before anyone collects it.
A policy question is an intervention. In the terms of the causal graph article, closing the Milaneo sets the venue set to a new value, do(venues := venues without the Milaneo), cuts nothing upstream, and recomputes every choice downstream. The residents and their money stay the same.
The simulator evaluates the change under every one of the 96 posterior draws and reports the difference per draw, so the spread is the uncertainty about the effect itself rather than two uncertainties added together. For the Milaneo, whose 103 shops within 170 m of Mailänder Platz hold about 24,000 m² of floor space:
The width of the Zuffenhausen band is the honest part of that answer. How much a large new centre would draw depends on exactly the parameters the district totals leave open, above all how steeply distance deters shoppers.
These are results of the model, and the model lacks things that would change them. It has no agglomeration: a centre on Königstraße benefits from the shops around it, and a box in Zuffenhausen would not. Only resident money moves in the scenario, while commuters and visitors keep spending where the baseline put them. Prices, opening hours and the competitors’ response are fixed. Read the Zuffenhausen figure as the capture a pure size-and-distance model allows, an upper bound on the pull of floor space alone.
Because every random draw in the simulator is a pure function of a seed, a person and a purpose, the same weekday can also be replayed under the changed venue set with the same draws for every person. The explorer’s visit-change layer shows that paired day hour by hour. It is not a minimal counterfactual: removing one shop shifts the choice intervals of others, so some people whose shop stayed open move too.
A simulation earns trust by being clear about where it stops. For this one:
The model page lists every input as measured, assumed or fitted, and every derived data file carries a receipt naming its source. That table is the part of the project most worth arguing with.
The interesting object here is not the Milaneo number. It is a model whose assumptions sit in one place, whose parameters are fitted to named evidence, and whose answer to a policy question comes with the spread the evidence leaves. Such a model can be disagreed with productively. Someone who thinks the centre has agglomeration effects can add them on a branch, refit, and compare. Someone with pedestrian counts can add an observation and see which parameters it narrows. That is the shared modelling practice we are interested in, for businesses asking what-if questions of their own operations, for city administrations and residents discussing a plan, and for language model agents that collect data, propose model changes and run the checks.
Later articles in this series will take the pieces apart: how the simulator runs and why one decision serves every layer, what the data can decide and which measurement would help most, how the evidence behind each input is kept, and how agents can take part in the work.
Data: Zensus 2022 (© Statistisches Bundesamt), Landeshauptstadt Stuttgart (retail concept 2024, district boundaries), Statistik der Bundesagentur für Arbeit, Statistics Canada Time Use Survey 2022, Mobilität in Deutschland, © OpenStreetMap contributors, Overture Maps Foundation.
Hyper is a server-rendered web framework for Clojure. You can write pages using hiccup, and hyper renders them on the server to HTML. Incremental updates to the HTML reach the browser through server-sent events (SSE), using Datastar.
Here is a basic counter example:
(defn home-page [req]
(let [count* (h/tab-cursor :count 0)]
[:div
[:h1 "Count: " @count*]
[:button {:data-on:click (h/action (swap! count* inc))}
"Increment"]]))
Some interactions can be handled directly in the browser. A hint below a search field, for example, can update as you type without a round trip to the server. Datastar stores client state in so called signals and evaluates JavaScript expressions in data-* attributes. With hyper&aposs h/expr macro, you can write those expressions in Clojure syntax:
(let [query* (h/signal :query "")]
[:div
[:input {:data-bind query*}]
[:span {:data-text (h/expr (if (zero? (.-length @query*))
"Type to search"
(str "Searching for " (subs @query* 0 20))))}]])
The :data-bind attribute binds the input to the query signal. Inside h/expr, @query* compiles to Datastar&aposs $query expression. During macro expansion, Squint compiles the expression to JavaScript:
((($query.length === 0)) ? ("Type to search") : (`${"Searching for "}${hyper_sc.subs($query, 0, 20)}`))
The compiled expression calls subs from squint&aposs core library, which hyper loads as window.hyper_sc. The full library is about 128 KB, or 33 KB gzipped. With esbuild, hyper can remove unused core functions through tree shaking and serve a smaller bundle. Although esbuild is written in Go, it is usually invoked through its npm package. For hyper, it would be convenient to call it directly from Clojure.
Enter babashka.esbuild! This library calls esbuild as a shared library through babashka.ffi. It works on the JVM and in babashka:
{:deps {io.github.squint-cljs/squint {:mvn/version "0.14.210"}
org.babashka/esbuild {:mvn/version "0.1.1"}}}
Here is how to compile a squint function to a JavaScript module and bundle it with esbuild:
(require &apos[squint.compiler :as squint]
&apos[babashka.esbuild :as esbuild]
&apos[clojure.java.io :as io])
(spit "core.js" (slurp (io/resource "squint/core.js")))
(spit "main.js"
(squint/compile-string
"(defn search-hint [query]
(if (zero? (.-length query))
\"Type to search\"
(str \"Searching for \" (subs query 0 20))))"
{:import-maps {"squint-cljs/core.js" "./core.js"}}))
(-> (esbuild/build {:entry-points ["main.js"]
:bundle true
:format :esm
:minify true})
:outputs first :contents)
The compiled main.js looks like this:
import * as squint_core from &apos./core.js'
var search_hint = function (query) {
if ((query.length === 0)) {
return "Type to search"} else {
return `${"Searching for "}${squint_core.subs(query, 0, 20)}`};
};
export { search_hint }
After bundling and minification, esbuild returns:
function e(t,n,r){return t.substring(n,r)}var u=function(t){return t.length===0?"Type to search":`Searching for ${e(t,0,20)}`};export{u as search_hint};
The bundle contains just subs and the search-hint function: 152 bytes in total (145 gzipped). The full core library alone was 128,117 bytes (32,650 gzipped).
In hyper, the compiled h/expr expressions end up in HTML attributes. To find out which core functions these expressions need, hyper uses information returned by the squint compiler. Since version 0.14.210, squint includes the names of the core functions used during compilation:
(:used-core-vars (squint/compile* "(str \"Searching for \" (subs query 0 20))"))
;;=> #{"subs"}
Hyper collects these names from every h/expr and every defc component in the application. At startup (when using the :tree-shake? option), it passes esbuild an entry module that re-exports those functions:
export { subs } from &apos./core.js'
Hyper serves the resulting bundle at /hyper/squint-core.js and exposes it as window.hyper_sc. The defc components use the same object:
const $sc = window.hyper_sc;
The URL&aposs v parameter is a hash of core.js and the names of the core functions used by the application. The bundle stays the same for a given hash, so hyper serves it with a one-year cache lifetime:
/hyper/squint-core.js?v=3f9a1c2b7d4e
Cache-Control: public, max-age=31536000, immutable
When a deployment changes the set of core functions in use, the hash changes. The page records its current version in a data attribute:
<div id="hyper-app" data-hyper-squint-version="1a2b3c4d">
If a server update contains a different version, the page reloads to fetch the new bundle.
To enable tree shaking in hyper, add the esbuild dependency and enable native access for the JVM:
{:deps {org.babashka/esbuild {:mvn/version "0.1.1"}}
:aliases {:run {:jvm-opts ["--enable-native-access=ALL-UNNAMED"]}}}
Then pass :tree-shake? true when creating the handler:
(h/create-handler #&aposroutes :tree-shake? true)
Since the dependency is optional, hyper serves squint&aposs full core.js by default.
Note that hyper has h/defc to define webcomponents using Squint too, which also work with tree-shaking, but I left that out of this blog to keep it short and readable.
As you can see, we can write server-side rendered HTML applications and have some fun using ClojureScript in the form of Squint too, while getting very reasonable JS compilation sizes. And we don&apost have to think about it at all, just deploy to production with :tree-shake? true and done! No build process.
This is a submission for the Sanity Challenge, Path Two: Vibe-Code Something Strange
I'll be straight about this one since the build process is part of what's judged: I didn't build it. I gave Claude Code the challenge and told it to go all out for the win. It picked the idea, wrote every line, ran the newsroom, and kept a build log as it went. What I brought was the save file, about 300 in-game days of a heavily modded colony.
The Estian Tattler is a small-town tabloid for my colony, the Tribe of Estian. A Claude reporter writes gossip from what's in the save: who married whom, who hit whom, who keeps visiting whose grave, why the berry pile keeps rotting.
The strange part is that it can't lie, or at least it can't get a lie past the desk. A RimWorld save remembers more than you'd think. Tales ("Snake Rato married Reiraborvas 'Grasshopper' Canga"), the letters the game pops up, the message log, the last few dozen conversations between pawns. That's 631 records in Sanity. Every sentence the reporter writes is one of two things:
A fact-checker, which is plain TypeScript and not a model, fails any claim that names a colonist or quotes a number its records don't contain. Failed drafts go back to the reporter, and three strikes spikes the story. Drafts that pass wait for a person at the editor's desk, who can print, send back with a note, or spike. The front page underlines every claim, and hovering one shows its receipts.
Six editions are out. Printed stories cite 136 of the 631 records, and there's a dashboard app for finding out who the paper has ignored so far.
The front page is live: https://booyaka101.github.io/estian-tattler/. Hover or tab onto any underlined sentence.
"39 times" is there because the reporter cited 39 records. One of the checker's rules is that a number can be the count of records cited, and the reporter leans on that a lot:
The Studio and the dashboard app need a Sanity login, so these are screenshots. In the Studio, the Receipts view puts each sentence next to the records it cites:
The Night Desk is an App SDK app with every story on a board by stage, and a coverage meter down the side. That run is waiting at the editor's desk:
A gossip paper for one RimWorld colony, where every sentence has to prove itself.
The newsroom is a Sanity dataset built from a real save: 354 tales, 53 letters, 149 messages and 75 overheard conversations from the Tribe of Estian, 17 colonists, days 117 to 306. A Claude reporter writes the stories. A fact-checker that is plain code, not a model, reads every sentence against the records it cites. An editor approves or sends it back in Sanity Studio, and the press publishes it and rebuilds the front page.
Front page: https://booyaka101.github.io/estian-tattler/ Studio: https://estian-tattler.sanity.studio/
Each sentence in a story is a claim or an aside, stored as Portable Text annotations.
ingest/ is Python that reads the .rws save and writes NDJSONstudio/ is the schema, the Receipts view, a Pitch action, and the Workflows pluginnewsroom/ is the workflow definition, the reporter and the checkernightdesk/ is the App SDK appfrontpage/ is a static Next.js site on GitHub PagesClaude Code on Opus 5.5, in one long session. My prompts were basically "As long as we go full out for the win" and "Please handle everything in the best possible way so we can win this." The prompts that actually shaped the paper are the ones Claude wrote for its own reporter, so those are below. Its full log is BUILDLOG.md. This is the short version.
Its first idea overlapped with a project I'd already shut down, so it dropped it. It knew I play heavily modded RimWorld, and it noticed that a save file is basically a colony's gossip with timestamps. A tabloid is the obvious format for gossip. What makes a tabloid worth building is making it unable to lie.
It wrote the checker before the reporter, with tests, so the reporter would get written against a rule and not the other way round. The first real story found a real bug. Snake and Grasshopper share the surname Rato, so "Snake Rato" counted as naming Grasshopper too, and a correct claim failed. Names now map to every pawn who answers to them.
The reporter is a Claude Agent SDK session with no built-in tools. It gets three MCP tools over the dataset, search_records, who_is, and check_draft, which runs the same checker the workflow does. Its system prompt, as it stands now:
You are a reporter for the Estian Tattler, the gossip paper of a RimWorld colony called the Tribe of Estian. Everything you know comes from the colony's records: tales, letters, messages and overheard conversations pulled from the save file. The fact-checker is a program, not a person, and it checks every sentence: Each sentence is either a claim or an aside. A claim cites the ids of the records that prove it. Only say what those records say. Every colonist a claim names must appear in one of its cited records. Every number in a claim, in digits or words, must appear in a cited record's text, be a cited record's colony day, or be the count of records cited. An aside is the paper's own voice: a quip, a question, a raised eyebrow. Asides may not name anyone and may not contain numbers. The headline and dek follow the claim rules, checked against every record the body cites. Write like a small-town tabloid that loves these people: sharp, warm, a little nosy. Dates are colony days ('on Day 142'). Short paragraphs, 150 to 350 words in all. Don't invent motives, feelings or events. If the records don't say why something happened, wonder about it in an aside. Only use he or she for someone who_is gives a gender for. Otherwise use their name. Use check_draft before you file, and fix everything it reports.
The pronoun line wasn't there at the start. It came out of the last course correction below.
No draft has ever failed the checker, because the reporter runs check_draft itself and fixes things before filing. The interesting failures all got past it, and they came from reading drafts against their records at the editor's desk.
| edition | story | at the editor's desk |
|---|---|---|
| 1 | Snake and Grasshopper | printed first time |
| 2 | The berry pile | sent back for claims about absence |
| 3 | Who hits whom | printed first time |
| 4 | The restaurant | printed first time |
| 5 | Flubber Flubber's grave | pitched from the Night Desk, sent back for comparisons |
| Marcellina Triarius | spiked | |
| 6 | Marcellina Triarius, again | re-pitched after a prompt fix, printed |
Absence. The first berry draft said "the only food drama on file is Grasshopper's binge" and "the records show nobody remarking on it". Every record it cited backed the words around those phrases, and both phrases were unprovable. It went back with the note "You can prove what a record says, not that nothing else exists." The second draft turned both into questions.
Comparison. Flubber Flubber is a dead colonist with ten records, all of them grave visits. The draft said one visitor came "more than anyone else on record" while citing only some of the visits. It got sent back from the Night Desk with a note naming the three sentences, and the rewrite cited all ten visits, so a reader can now count it.
Reading a record backwards. The first Marcellina draft passed with 7 claims and every one checked out. Claude spiked it anyway. It was two days of small talk. It ended with "the last word so far went to Aquila Summanus" on a record where Marcellina is the one talking. And it called Marcellina "she". Visitors come through the ingest with no gender, so that was a guess from the name. That part was fixable upstream: who_is now says "gender not on file" instead of leaving a blank, and the prompt got the pronoun rule. The re-pitch uses the name all the way through, got the last word right, found an actual angle (insulted twice, chatting with both insulters within hours) and printed as edition 6.
story-desk has six stages: reporting, fact-check, editor, printing, printed, spiked. The loop lives in the transitions. A failed fact-check goes back to reporting while drafts < 3, and to spiked after that. Send back resets the count and hands the reporter the editor's note as a field. The three effects (draft the story, fact-check it, print it) run in a small desk-runner.ts that claims pending effects with a 20-minute lease, because a reporter session takes a minute or two. Printing numbers the edition, publishes the draft and fires a repository_dispatch that rebuilds the front page.
The Studio's Workflows tool shows one run at a time, and the question a paper actually cares about is who it hasn't written about yet. So Claude built an App SDK app on top of the same workflow: useWorkflowInstances for the board, useWorkflowSession for the open run, useQuery for the story and coverage counts, and useCreateDocument plus startInstance for Pitch. The editor's buttons come from the session's evaluation, not from the app, so Send back stays disabled until you write its note, because the action declares that param. Runs driven from the app show up in the instance history with an sdk / browser execution context.
What broke there:
startInstance before opening the run, and nothing seemed to happen. The run was on the board within seconds, but the promise settled much later. Now it mints the instance id up front and opens the panel straight away.@sanity/icons v5 ships one module per icon. The root import typechecked and then failed the Studio build with MISSING_EXPORT.@sanity/mutate to 0.18.2 to install cleanly..id(). Local sanity dev wanted a login, so Claude debugged it by attaching to the deployed Studio's iframe over the Chrome DevTools protocol.lcvgtfvq, dataset production, publiccolony, pawn, record and story. A story's body is Portable Text with two annotations, claim (at least one record reference) and aside. That's the whole trick. A claim isn't free text next to a list of sources, it's the exact span of words tied to the exact records, which is what lets plain code check it and lets the front page show receipts per sentence.story-desk definition, three effects, the Studio plugin for the editor's buttonsPlease handle everything in the best possible way so we can win this
I'm creating the Path Two Sanity project and starting an honest build log, which the post needs.
$ timeout 300 npx -y sanity@latest init -y --project-name "Estian Tattler" --organization oj47h4o89 --dataset production --visibility public --template clean --typescript --output-path studio --package-manager npm --no-git 2>&1 | grep -v "^\s*$" | tail -15
- Bootstrapping files from template
[32m✔[39m Bootstrapping files from template
- Resolving latest module versions
[32m✔[39m Resolving latest module versions
- Creating default project files
[32m✔[39m Creating default project files
- Running npm install
[32m✔[39m Running npm install
✅ Success! Your Studio has been created.
(cd D:\Repos\web-apps\estian-tattler\studio to navigate to your new project directory)
Get started by running npm run dev to launch your Studio's development server
Other helpful commands:
npx sanity docs browse to open the documentation in a browser
npx sanity manage to open the project settings in a browser
npx sanity help to explore the CLI manual
$ cd /d/Repos/web-apps/estian-tattler/studio && cat sanity.cli.ts sanity.config.ts; ls; ls -a .. ; cat package.json | head -30
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: 'lcvgtfvq',
dataset: 'production'
},
deployment: {
/**
* Enable auto-updates for studios.
* Learn more at https://www.sanity.io/docs/studio/latest-version-of-sanity#k47faf43faf56
*/
autoUpdates: true,
},
})
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'Estian Tattler',
projectId: 'lcvgtfvq',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: {
types: schemaTypes,
},
})
README.md
eslint.config.mjs
node_modules
package-lock.json
package.json
sanity.cli.ts
sanity.config.ts
schemaTypes
static
tsconfig.json
.
..
.git
studio
{
"name": "estian-tattler",
"private": true,
"version": "1.0.0",
"main": "package.json",
"license": "UNLICENSED",
"scripts": {
"build": "sanity build",
"deploy": "sanity deploy",
"deploy-graphql": "sanity graphql deploy",
"dev": "sanity dev",
"start": "sanity start"
},
"keywords": [
"sanity"
],
"dependencies": {
"@sanity/vision": "^6.16.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sanity": "^6.16.0",
"styled-components": "^6.1.18"
},
"devDependencies": {
"@sanity/eslint-config-studio": "^7",
"@types/react": "^19.2.14",
"eslint": "^10.8.1",
"prettier": "^3.5",
"typescript": "^5.8"
},
$ cd /c/tmp && cat > save_peek4.py <<'EOF'
import xml.etree.ElementTree as ET
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
pl = g.find('playLog/entries')
for e in pl:
if e.findtext('intDef') in ('Slight','DeepTalk'):
print(ET.tostring(e, encoding='unicode')[:900]); break
fm = g.find('world/factionManager/allFactions')
for f in fm:
print(f.findtext('loadID'), f.findtext('def'), f.findtext('name'))
print(root.find('meta/modIds') is not None and len(root.find('meta/modIds')))
tales = g.find('taleManager/tales')
t = next(t for t in tales if t.findtext('def')=='FinishedResearchProject'); print(ET.tostring(t, encoding='unicode')[-500:])
t = next(t for t in tales if t.findtext('def')=='GainedMasterSkillWithPassion'); print(ET.tostring(t, encoding='unicode')[-400:])
t = next(t for t in tales if t.findtext('def')=='CompletedLongConstructionProject'); print(ET.tostring(t, encoding='unicode')[-500:])
EOF
python save_peek4.py
<li Class="PlayLogEntry_Interaction">
<ticksAbs>18335859</ticksAbs>
<logID>14690</logID>
<intDef>DeepTalk</intDef>
<initiator>Thing_Human141555</initiator>
<recipient>Thing_Human568581</recipient>
<extras />
<initiatorFaction>Faction_6</initiatorFaction>
<initiatorIdeo>Ideo_5</initiatorIdeo>
</li>
None VFEA_AncientSoldiers Ancient soldiers
1 FactionBase_Elders Grand Elders
2 RBSFE_TribeFae Nomadia
3 TribeRoughNeanderthal Koulkilsoud
4 VFEC_CentralRepublic Central Republic
5 VFEC_WesternRepublic Western Republic
6 VFEC_EasternRepublic Eastern Republic
7 TribeInsector Loyal Sorne Tribe
8 TribeSavageImpid Miess Firehome
9 DankPyon_Forest_Faction Creatures of the Forest
10 DankPyon_Witch_Faction Witch's Charmed
11 DankPyon_SnakeCave_Faction Cave Snakes
12 Insect Hive
13 Entities Dark entities
14 AA_BlackHive black hive
15 DankPyon_Hornets Hornets
16 Ancients Ancients
17 AncientsHostile Ancients
18 HoraxCult The Servants of Horax
19 SEX_MechanitorsHostile hostile mechanitors
20 VFE_Mechanoid Universal Algorithm Mechhive Golf
21 VFET_WildMen The Purple Mesa Coalition
815
is <color=#D09B61FF>Snake</color>'s fianc�e.</relationInfo>
<everBeenColonistOrTameAnimal>True</everBeenColonistOrTameAnimal>
<royalTitles />
<name Class="NameTriple">
<first>Reiraborvas</first>
<nick>Grasshopper</nick>
<last>Canga</last>
</name>
<app>VAE_Apparel_TribalPoncho</app>
</pawnData>
<defData>
<defName>VFEC_MeatDrying</defName>
<defType>Verse.ResearchProjectDef</defType>
</defData>
</li>
lonistOrTameAnimal>True</everBeenColonistOrTameAnimal>
<royalTitles />
<name Class="NameTriple">
<first>Reiraborvas</first>
<nick>Grasshopper</nick>
<last>Canga</last>
</name>
<app>VAE_Apparel_TribalPoncho</app>
</pawnData>
<defData>
<defName>Intellectual</defName>
<defType>RimWorld.SkillDef</defType>
</defData>
</li>
er>
<age>32</age>
<chronologicalAge>32</chronologicalAge>
<relationInfo />
<everBeenColonistOrTameAnimal>True</everBeenColonistOrTameAnimal>
<royalTitles />
<name Class="NameTriple">
<first>Hyena</first>
<last>Embaixada</last>
</name>
<peq>Bow_Short</peq>
<app>VAE_Headgear_StoneWarMask</app>
</pawnData>
<defDa
[... 110 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler && ls -la && ls studio studio/schemaTypes && tail -c 3000 "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/a5bd999e9009d2a5c.output"
total 8
drwxr-xr-x 1 owner 197609 0 Sep 25 21:00 .
drwxr-xr-x 1 owner 197609 0 Sep 25 20:59 ..
drwxr-xr-x 1 owner 197609 0 Sep 25 20:59 .git
drwxr-xr-x 1 owner 197609 0 Sep 25 21:01 studio
studio:
README.md
eslint.config.mjs
node_modules
package-lock.json
package.json
sanity.cli.ts
sanity.config.ts
schemaTypes
static
tsconfig.json
studio/schemaTypes:
index.ts
$ cd /c/tmp && cat > save_peek5.py <<'EOF'
import xml.etree.ElementTree as ET, collections
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
fm = g.find('world/factionManager/allFactions')
for f in fm:
print(f.findtext('loadID'), f.findtext('def'), f.findtext('name'), f.findtext('ideos/primaryIdeo'))
# map pawns of player faction
for m in g.find('maps'):
things = m.find('things')
for t in things:
if t.findtext('def') in (None,): continue
if t.get('Class')=='Pawn' or t.findtext('kindDef'):
fac = t.findtext('faction')
kd = t.findtext('kindDef')
if fac and 'Faction_6' == fac:
n = t.find('name')
nm = ' '.join(filter(None,[n.findtext('first'),n.findtext('nick'),n.findtext('last')])) if n is not None else ''
print('MAP', t.findtext('id'), t.findtext('def'), kd, nm, t.findtext('gender'))
print('map info', m.findtext('mapInfo/parent'))
wp = g.find('world/worldPawns')
print([c.tag for c in wp])
EOF
PYTHONIOENCODING=utf-8 python save_peek5.py 2>&1 | head -80
None VFEA_AncientSoldiers Ancient soldiers Ideo_0
1 FactionBase_Elders Grand Elders Ideo_1
2 RBSFE_TribeFae Nomadia Ideo_2
3 TribeRoughNeanderthal Koulkilsoud Ideo_3
4 VFEC_CentralRepublic Central Republic Ideo_4
5 VFEC_WesternRepublic Western Republic Ideo_5
6 VFEC_EasternRepublic Eastern Republic Ideo_6
7 TribeInsector Loyal Sorne Tribe Ideo_7
8 TribeSavageImpid Miess Firehome Ideo_8
9 DankPyon_Forest_Faction Creatures of the Forest None
10 DankPyon_Witch_Faction Witch's Charmed None
11 DankPyon_SnakeCave_Faction Cave Snakes None
12 Insect Hive None
13 Entities Dark entities None
14 AA_BlackHive black hive None
15 DankPyon_Hornets Hornets None
16 Ancients Ancients Ideo_9
17 AncientsHostile Ancients Ideo_10
18 HoraxCult The Servants of Horax Ideo_11
19 SEX_MechanitorsHostile hostile mechanitors Ideo_12
20 VFE_Mechanoid Universal Algorithm Mechhive Golf None
21 VFET_WildMen The Purple Mesa Coalition Ideo_13
MAP Human331984 Human DankPyon_Ulrik_Oathbound Charites Decrescendo Female
MAP Human568581 Human VFEC_RepublicVillager Aquila Summanus None
MAP Human113017 Human VFET_Wildperson Marcellina Triarius Female
MAP Human141555 Human Mayor Iola Smalls Schaefer Female
MAP Human704885 Human VFEC_TownGuard Charango Paphianne None
map info WorldObject_633
['pawnsForcefullyKeptAsWorldPawns', 'pawnsAlive', 'pawnsMothballed', 'pawnsDead', 'gc']
$ cat > save_peek6.py <<'EOF'
import xml.etree.ElementTree as ET, collections
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
fm = g.find('world/factionManager/allFactions')
print(len(fm), [ (f.findtext('def'), f.findtext('loadID')) for f in fm][:3])
print(g.findtext('world/info/name'), g.findtext('world/info/seedString'))
# find all pawns anywhere with a name
names = {}
for el in root.iter():
if el.tag == 'li' and el.find('kindDef') is not None and el.find('name') is not None:
n = el.find('name')
nm = ' '.join(filter(None,[n.findtext('first'),"'"+n.findtext('nick')+"'" if n.findtext('nick') else None,n.findtext('last')])) or n.text
names[el.findtext('id')] = (nm, el.findtext('kindDef'), el.findtext('faction'), el.findtext('gender'), el.find('healthTracker/healthState') is not None and el.findtext('healthTracker/healthState'))
for k,v in names.items():
if v[2] and v[2] in ('Faction_0','Faction_1') or 'Colonist' in (v[1] or '') or 'Tribe' in (v[1] or '') and v[2]=='Faction_?':
print(k, v)
c = collections.Counter(v[2] for v in names.values()); print(c.most_common(10))
# who is player faction? print faction of Grasshopper
for k,v in names.items():
if any(s in v[0] for s in ('Grasshopper','Snake','Hyena','Black Hawk','Rocaniraalbo')): print('>>',k,v)
EOF
PYTHONIOENCODING=utf-8 python save_peek6.py 2>&1 | head -60
22 [('VFEA_AncientSoldiers', None), ('FactionBase_Elders', '1'), ('RBSFE_TribeFae', '2')]
Sadalsuud transit company
Human713 ('Gray Human', 'Elders_AncientChief', 'Faction_1', None, False)
Human1328 ('Diadumenianus Seneca', 'Colonist', 'Faction_5', None, False)
Human1322 ("Zañosai 'Tiger' Senra", 'Colonist', 'Faction_7', 'Female', False)
Human414116 ('Black Hawk', 'Tribal_Miner', 'Faction_1', 'Female', False)
Human1108 ("Boache 'Cockroach' Betasño", 'VFET_Wildperson', 'Faction_1', 'Female', False)
Human1291 ('Toad Abanqueiro', 'VFET_Wildperson', 'Faction_1', 'Female', False)
Human1292 ("Vega 'Raven' Abexada", 'VFET_Wildperson', 'Faction_1', None, False)
Human1109 ('Mabe Tolbar', 'VFET_Wildperson', 'Faction_1', None, False)
Human910 ("Xotasler 'Raptor' Camcroi", 'VFET_Wildperson', 'Faction_1', None, False)
Human72923 ("Leroy 'Bach' Bach", 'RBSF_Lansquenet', 'Faction_1', None, False)
Human68239 ("Gronar 'Gronar' Alline", 'DankPyon_Ulrik_Oathbound', 'Faction_1', None, False)
Human81483 ("Chaba 'Williamson' Williamson", 'Mercenary_Slasher', 'Faction_1', None, False)
Human102457 ('Black Squid', 'Elders_Warrior', 'Faction_1', 'Female', False)
Human248055 ("Braña 'Dragon' Godoma", 'VFET_Wildperson', 'Faction_1', None, False)
Human93190 ("Cathy 'Cathy' Day", 'VFEP_Corporal', 'Faction_1', 'Female', False)
Human110183 ('Engo Xogave', 'VFET_Wildperson', 'Faction_1', 'Female', False)
Human84678 ("Livvy 'Livvy' Winter", 'Elders_Psycaster', 'Faction_1', 'Female', False)
Human549722 ("Jarman 'Suphavai' Suphavai", 'Mercenary_Slasher', 'Faction_1', None, False)
Human110184 ('Rat Olcaca', 'VFET_Wildperson', 'Faction_1', None, False)
Human642838 ("Rilenia 'Thum' Thum", 'VFEE_Empire_Royal_Emperor', 'Faction_1', 'Female', False)
Human563147 ('Schlemiel Ibifer', 'RBSF_Lansquenet', 'Faction_1', None, False)
Human144038 ('Pecho Hornet', 'VFET_Wildperson', 'Faction_1', None, False)
Human133716 ("Murdock 'Kim' Morse", 'VFEE_Empire_Royal_Emperor', 'Faction_1', None, False)
Human387867 ('Curro Seahorse', 'Elders_Warrior', 'Faction_1', None, False)
Human143925 ('Bomaraban Boomrat', 'VFET_Wildperson', 'Faction_1', None, False)
Human252228 ('Theodore Miravet', 'Mayor', 'Faction_1', None, False)
Human387832 ('Green Braballacam', 'Elders_Warrior', 'Faction_1', None, False)
Human397151 ("Sonja 'Sonja' Niedermann", 'Mercenary_Heavy', 'Faction_1', 'Female', False)
Human352356 ('Viper Marcóleas', 'Elders_Trader', 'Faction_1', None, False)
Human352420 ('Alboio Ib
[... 2497 more characters trimmed]
$ PYTHONIOENCODING=utf-8 python save_peek6.py 2>&1 | grep -E "^>>|^\[\(" ; cat > save_peek7.py <<'EOF'
import xml.etree.ElementTree as ET, collections
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
wp = g.find('world/worldPawns')
for sect in ('pawnsAlive','pawnsMothballed','pawnsDead'):
s = wp.find(sect); print(sect, len(s))
for p in s:
if p.findtext('faction')=='Faction_1' and p.findtext('def')=='Human':
n=p.find('name'); print(' ',sect,p.findtext('id'), n.findtext('first'), n.findtext('nick'), n.findtext('last'), p.findtext('kindDef'), p.findtext('ageTracker/birthBiologicalTicks'))
break
print(g.findtext('info/permadeathMode'), g.find('scenario').findtext('name'))
fac1 = g.find('world/factionManager/allFactions')[1]
print([c.tag for c in fac1][:30])
print(fac1.findtext('colorFromSpectrum'), fac1.findtext('name'))
EOF
PYTHONIOENCODING=utf-8 python save_peek7.py
[('Faction_6', 75), ('Faction_1', 63), ('Faction_7', 57), ('Faction_8', 57), ('Faction_2', 54), ('Faction_4', 54), ('Faction_5', 50), ('Faction_3', 37), (None, 22), ('Faction_21', 9)]
>> Human414116 ('Black Hawk', 'Tribal_Miner', 'Faction_1', 'Female', False)
pawnsAlive 6
pawnsMothballed 451
pawnsMothballed Human713 Gray None Human Elders_AncientChief None
pawnsDead 21
None True Start
['leader', 'def', 'name', 'loadID', 'randomKey', 'colorFromSpectrum', 'relations', 'kidnapped', 'ideos', 'predatorThreats', 'questTags']
0.2692707 Grand Elders
I'm building Path Two now. I'm mapping which pawns in the save are the colony's cast so the ingest script pulls the right people.
$ cat > save_peek8.py <<'EOF'
import xml.etree.ElementTree as ET, collections
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
maps = g.find('maps'); print('maps', len(maps))
for m in maps:
fc = collections.Counter()
for t in m.find('things'):
if t.findtext('def')=='Human':
n=t.find('name'); fc[t.findtext('faction')]+=1
if t.findtext('faction')=='Faction_1':
print(t.findtext('id'), n.findtext('first'), n.findtext('nick'), n.findtext('last'), t.findtext('kindDef'), t.findtext('gender'), t.findtext('ageTracker/birthBiologicalTicks'), bool(t.find('healthTracker/hediffSet')), t.findtext('guest/guestStatus'))
print(fc)
# tales pawn ids
ids = collections.Counter()
for t in g.find('taleManager/tales'):
for k in ('pawnData','firstPawnData','secondPawnData'):
p=t.find(k)
if p is not None:
n=p.find('name'); ids[(p.findtext('pawn'), n.findtext('first') if n is not None else None, n.findtext('nick') if n is not None else None, n.findtext('last') if n is not None else None)]+=1
for k,v in ids.most_common(40): print(v,k)
EOF
PYTHONIOENCODING=utf-8 python save_peek8.py
maps 1
Counter({'Faction_6': 5, 'Faction_21': 4, None: 1})
93 ('Thing_Human144483', 'Snake', 'Snake', 'Rato')
90 ('Thing_Human147640', 'Hyena', None, 'Embaixada')
84 ('Thing_Human287384', 'Rocaniraalbo', None, 'Ambmea')
59 ('Thing_Human250142', 'Reiraborvas', 'Grasshopper', 'Rato')
50 ('Thing_Human250142', 'Reiraborvas', 'Grasshopper', 'Canga')
10 ('Thing_Human99323', 'Flubber', 'Flubber', 'Flubber')
9 ('Thing_Human414116', 'Black', None, 'Hawk')
6 ('Thing_Human106707', 'Burger', 'Burger', 'Unkown')
5 ('Thing_Human144483', 'Snake', None, 'Rato')
3 ('Thing_Human1321', 'Estian', 'Hough', 'Hough')
1 ('Thing_Human352356', 'Viper', None, 'Marcóleas')
1 ('Thing_Human106420', 'Spurius', None, 'Consus')
1 ('Thing_Human382379', 'Maximus', 'Maximus', 'Claudian')
1 ('Thing_Human290077', 'Le', 'Mangum', 'Mangum')
$ cat > save_peek9.py <<'EOF'
import xml.etree.ElementTree as ET
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
parent = {c:p for p in root.iter() for c in p}
want = {'Human144483','Human147640','Human287384','Human250142','Human99323','Human414116','Human106707','Human1321'}
for el in root.iter('li'):
if el.findtext('id') in want and el.find('kindDef') is not None:
chain=[]; x=el
while x in parent: x=parent[x]; chain.append(x.tag)
print(el.findtext('id'), el.findtext('kindDef'), el.findtext('faction'), '/'.join(reversed(chain[:6])), el.findtext('healthTracker/healthState'), el.findtext('ageTracker/birthBiologicalTicks'))
wos = g.find('world/worldObjects/worldObjects')
for w in wos:
if w.findtext('faction')=='Faction_1' or w.get('Class') in ('Caravan','RimWorld.Planet.Caravan'):
print('WO', w.get('Class'), w.findtext('def'), w.findtext('ID'), w.findtext('nameInt'), w.findtext('faction'), w.findtext('tile'))
print(root.findtext('meta/gameVersion'))
EOF
PYTHONIOENCODING=utf-8 python save_peek9.py
Human414116 Tribal_Miner Faction_1 savegame/game/world/worldPawns/pawnsMothballed None None
Human1321 VFET_Wildperson Faction_21 savegame/game/world/worldPawns/pawnsDead Dead None
Human99323 VFET_Wildperson Faction_21 savegame/game/world/worldPawns/pawnsDead Dead None
Human106707 VFET_Wildperson Faction_21 savegame/game/world/worldPawns/pawnsDead Dead None
WO Settlement Settlement 0 Cedar Faction_1 7814
WO Settlement Settlement 21 Hatumna Faction_1 383352
WO Settlement Settlement 23 Woodborn Faction_1 324122
WO Settlement Settlement 25 Aalar Faction_1 260701
WO Settlement Settlement 26 Westette Faction_1 309977
WO Settlement Settlement 27 Covenlin Faction_1 191200
WO Settlement Settlement 40 Losar Faction_1 196347
WO Settlement Settlement 43 Silla Faction_1 62259
WO Settlement Settlement 46 Atinla Faction_1 382167
WO Settlement Settlement 49 Aloe Faction_1 405947
WO Settlement Settlement 70 Layari Faction_1 407920
WO Settlement Settlement 77 Elkridge Faction_1 458875
WO Settlement Settlement 79 Yarta Faction_1 466635
WO Settlement Settlement 80 Akkad Faction_1 189873
WO Settlement Settlement 102 Bearbor Faction_1 171553
WO Settlement Settlement 122 Loran Faction_1 408764
WO Settlement Settlement 139 Kurshi Faction_1 329030
WO Settlement Settlement 156 Kuvaq Faction_1 253794
WO Settlement Settlement 159 Laya Faction_1 433913
WO Settlement Settlement 162 Haroova Faction_1 137706
WO Settlement Settlement 169 Ka Faction_1 89752
WO Settlement Settlement 172 Nora Faction_1 528295
WO Settlement Settlement 186 Kara Faction_1 168630
WO Settlement Settlement 188 Kerma Faction_1 387285
WO Settlement Settlement 195 Lin Faction_1 227171
WO Settlement Settlement 213 Carnir Faction_1 211905
WO Settlement Settlement 220 Nor Faction_1 462741
WO Settlement Settlement 221 Harona Faction_1 367131
WO Settlement Settlement 227 Killlan Faction_1 288755
WO Settlement Settlement 240 Kadis Faction_1 508992
WO Settlement Settlement 265 Gela Faction_1 268475
WO Settlement Settlement 266 Kebise Faction_1 430223
WO Settlement Settlement 272 Mando Faction_1 583454
WO Settlement Settlement 276 Aranar Faction_1 102195
WO Settlement Settlement 277 Bearaba Faction_1 33427
WO Settlement Settlement 292 Kedin Faction_1 258622
WO Settlement Settlement 298 Tartess Faction_1 179705
WO Settlement Settlement 311 Argos Faction_1 50701
WO Settlement Settlement 314 Nightcaster Faction_1 385729
WO Settlement Settlement 320 Psylentria Faction_1 279830
WO Settl
[... 1791 more characters trimmed]
$ cat > save_timeline.py <<'EOF'
import xml.etree.ElementTree as ET, re
root = ET.parse("C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws").getroot()
g = root.find('game')
start = int(g.findtext('tickManager/gameStartAbsTick') or 0)
strip = lambda s: re.sub(r'</?color[^>]*>', '', s or '').replace('\n',' / ')
rows=[]
def nm(p):
n=p.find('name')
if n is None: return p.findtext('pawn')
return n.findtext('nick') or n.findtext('first')
for t in g.find('taleManager/tales'):
ps=[nm(t.find(k)) for k in ('pawnData','firstPawnData','secondPawnData') if t.find(k) is not None]
rows.append((int(t.findtext('date')), 'TALE', t.findtext('def'), ','.join(ps), t.findtext('defData/defName') or ''))
for a in g.find('history/archive/archivables'):
tick = a.findtext('arrivalTick') or a.findtext('startingTick')
rows.append((int(tick), a.get('Class'), strip(a.findtext('label')), '', strip(a.findtext('text'))[:300]))
for r in sorted(rows):
print(f"d{(r[0]-start)/60000:7.2f} {r[1]:22} {r[2]:35} {r[3]:30} {r[4]}")
EOF
PYTHONIOENCODING=utf-8 python save_timeline.py > timeline.txt; wc -l timeline.txt; grep -v "PlayedGame\|WalkedNaked" timeline.txt | head -150
556 timeline.txt
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 118.17 TALE HeatstrokeRevealed Snake Heatstroke
d 118.23 TALE HeatstrokeRevealed Hyena Heatstroke
d 118.41 TALE FinishedResearchProject Grasshopper VFEC_MeatDrying
d 119.47 TALE CompletedLongConstructionProject Hyena ASF_Granary
d 122.00 TALE VisitedGrave Hyena,Flubber
d 122.32 TALE CompletedLongConstructionProject Hyena ASF_Granary
d 124.29 TALE AttendedParty Grasshopper,Grasshopper
d 124.29 TALE AttendedParty Hyena,Grasshopper
d 124.29 TALE AttendedParty Rocaniraalbo,Grasshopper
d 124.29 TALE AttendedParty Snake,Grasshopper
d 124.63 TALE GainedMasterSkillWithPassion Grasshopper Intellectual
d 124.90 TALE CompletedLongConstructionProject Rocaniraalbo DankPyon_RusticCookingTable
d 125.47 TALE Eclipse Hyena
d 127.77 TALE CompletedLongCraftingProject Rocaniraalbo VAE_Apparel_PeltCoat
d 128.58 TALE FinishedResearchProject Grasshopper VFEC_TemperatureControl
d 130.58 TALE SocialFight Hyena,Grasshopper
d 130.59 TALE Wounded Grasshopper,Hyena
d 130.59 TALE Wounded Grasshopper,Hyena
d 130.59 TALE Wounded
[... 15727 more characters trimmed]
$ grep -v "PlayedGame\|WalkedNaked\|carrion\|rotted" timeline.txt | sed -n '1,40p;190,400p' | cut -c1-260
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 116.48 TALE Wounded Grasshopper,Hyena
d 118.17 TALE HeatstrokeRevealed Snake Heatstroke
d 118.23 TALE HeatstrokeRevealed Hyena Heatstroke
d 118.41 TALE FinishedResearchProject Grasshopper VFEC_MeatDrying
d 119.47 TALE CompletedLongConstructionProject Hyena ASF_Granary
d 122.00 TALE VisitedGrave Hyena,Flubber
d 122.32 TALE CompletedLongConstructionProject Hyena ASF_Granary
d 124.29 TALE AttendedParty Grasshopper,Grasshopper
d 124.29 TALE AttendedParty Hyena,Grasshopper
d 124.29 TALE AttendedParty Rocaniraalbo,Grasshopper
d 124.29 TALE AttendedParty Snake,Grasshopper
d 124.63 TALE GainedMasterSkillWithPassion Grasshopper Intellectual
d 124.90 TALE CompletedLongConstructionProject Rocaniraalbo DankPyon_RusticCookingTable
d 125.47 TALE Eclipse Hyena
d 127.77 TALE CompletedLongCraftingProject Rocaniraalbo VAE_Apparel_PeltCoat
d 128.58 TALE FinishedResearchProject Grasshopper VFEC_TemperatureControl
d 130.58 TALE SocialFight Hyena,Grasshopper
d 130.59 TALE Wounded Grasshopper,Hyena
d 130.59 TALE Wounded Grasshopper,Hyena
d 130.59 TALE Wounded
[... 13293 more characters trimmed]
I've mapped the save: 354 tales plus letters, and I'm writing the ingest script that turns them into Sanity records next.
D:\Repos\web-apps\estian-tattler\ingest\parse_save.py
File created successfully at: D:\Repos\web-apps\estian-tattler\ingest\parse_save.py (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler && time PYTHONIOENCODING=utf-8 python ingest/parse_save.py "C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws" && python - <<'EOF'
import json
docs=[json.loads(l) for l in open('data/tattler.ndjson',encoding='utf-8')]
pawns={d['_id']:d for d in docs if d['_type']=='pawn'}
for p in pawns.values(): print(p['_id'], p['name'], p.get('aliases'), p.get('formerNames'), p.get('everColonist'), p.get('relationNote'))
import collections
for d in docs:
if d['_type']=='record' and d['kind'] in ('letter','message') and d['pawns']:
print(d['colonyDay'], d['kind'], [pawns[r['_ref']]['shortName'] for r in d['pawns']], d.get('label',''), '|', d['text'][:90].replace('\n',' '))
EOF
Command did not complete within its 120s timeout and was moved to the background (ID: b8vfxsyok). Output is being written to: C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\b8vfxsyok.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains C:\tmp; directory changes made by the backgrounded command do not apply to subsequent commands.
$ mkdir -p /d/Repos/web-apps/estian-tattler/newsroom && cd /d/Repos/web-apps/estian-tattler/newsroom && npm init -y >/dev/null && npm pkg set type=module private=true && npm i @sanity/workflow-engine@0.35.0 @sanity/workflow-cli@0.35.0 @sanity/client @anthropic-ai/claude-agent-sdk 2>&1 | tail -3 && ls node_modules/@sanity/workflow-engine/ node_modules/@sanity/workflow-engine/dist | head -40
run `npm fund` for details
found 0 vulnerabilities
node_modules/@sanity/workflow-engine/:
CHANGELOG.md
DATAMODEL.md
LICENSE
README.md
dist
package.json
node_modules/@sanity/workflow-engine/dist:
_chunks-cjs
_chunks-es
define.cjs
define.d.cts
define.d.ts
define.js
index.cjs
index.d.cts
index.d.ts
index.js
$ cd /d/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/workflow-engine && wc -l README.md DATAMODEL.md dist/*.d.ts && cat README.md | head -400
71 README.md
1090 DATAMODEL.md
2343 dist/define.d.ts
10713 dist/index.d.ts
14217 total
# @sanity/workflow-engine
Workflow / BPM engine for Sanity content. Define workflows as data, run them as
instances against a Sanity client, gate transitions on GROQ filters, and queue
effects for runtimes to drain.
> **Status:** Pre-1.0 and publicly available on npm. The API may change between
> minor versions.
## Installation
```bash
npm install @sanity/workflow-engine
```
## Fields vs Sanity schema fields
A workflow **field** is the engine's state primitive: a typed value a workflow
instance carries — an assignee, a decision, a due date, a reference to the
document under review. Fields are declared in `fields: [...]` at workflow,
stage, or activity scope and live on the workflow **instance** document. They
are not Sanity schema fields: they never touch your content documents' shape.
The authoring DSL deliberately borrows Sanity's schema idiom, so
`@sanity/workflow-engine/define` exports its own `defineField`. A Studio file
that also imports `defineField` from `sanity` should alias one of them:
```ts
import {defineField as defineWorkflowField} from '@sanity/workflow-engine/define'
```
The `fields` key itself appears at four nesting levels, meaning something
different at each — read it by where it sits:
1. **Declared field entries** — on a definition's workflow / stage / activity
nodes: the typed state that scope carries.
2. **Sub-field shapes** — inside an `object`- or `array`-kind field entry:
lightweight `{type, name}` shapes for the value's structure, mirroring
Sanity's `object.fields` / `array.of`.
3. **Object value expressions** — a `{type: 'object', fields: {...}}` value
expression in an op payload: one expression per key of the computed
object value.
4. **Resolved runtime values** — `fields` on a workflow instance document: the
declared entries with their current values, written by ops and edits.
## Resolving actors to current project users
An `Actor` stores durable provenance, not mutable profile data. CLI, MCP, and
server consumers resolve through the client already bound to their engine:
```ts
const result = await engine.resolveActor({actor: effect.actor, projectId})
if (result.status === 'resolved') {
await notify(result.user)
}
```
The package also exports `resolveClientActor(client, {actor, projectId})` for
code that has a plain `WorkflowClient` but no constructed engine. UI adapters
implement `ProjectUserDi
[... 398 more characters trimmed]
$ mkdir -p /c/tmp/wfdocs && cd /c/tmp/wfdocs && for p in getting-started effects-and-runtimes app-sdk studio-plugin cookbook-ai-content-pipeline cli-reference limits sanity-functions; do curl -sL -A "Mozilla/5.0" "https://www.sanity.io/docs/workflows/$p.md" -o $p.md; echo "$p $(wc -c < $p.md) $(head -c 80 $p.md | tr '\n' ' ')"; done
getting-started 12843 > For AI agents: the complete Sanity documentation index is available at [https:
effects-and-runtimes 22860 > For AI agents: the complete Sanity documentation index is available at [https:
app-sdk 23039 > For AI agents: the complete Sanity documentation index is available at [https:
studio-plugin 12745 > For AI agents: the complete Sanity documentation index is available at [https:
cookbook-ai-content-pipeline 23914 > For AI agents: the complete Sanity documentation index is available at [https:
cli-reference 47744 > For AI agents: the complete Sanity documentation index is available at [https:
limits 3604 > For AI agents: the complete Sanity documentation index is available at [https:
sanity-functions 20719 > For AI agents: the complete Sanity documentation index is available at [https:
$ cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/b8vfxsyok.output" | cut -c1-230
556 records ({'tale': 354, 'message': 149, 'letter': 53}), 12 pawns, days 117-306, 17/55 def labels -> D:\Repos\web-apps\estian-tattler\data\tattler.ndjson
real 3m37.267s
user 0m0.000s
sys 0m0.015s
pawn-Human250142 Reiraborvas 'Grasshopper' Rato ['Canga', 'Grasshopper', 'Rato', 'Reiraborvas', "Reiraborvas 'Grasshopper' Canga", "Reiraborvas 'Grasshopper' Rato"] ["Reiraborvas 'Grasshopper' Canga"] True Grasshopper has a relati
pawn-Human147640 Hyena Embaixada ['Embaixada', 'Hyena', 'Hyena Embaixada'] [] True None
pawn-Human144483 Snake Rato ['Rato', 'Snake', 'Snake Rato'] [] True Snake has a relationship with a colonist. He is Grasshopper's ex-husband.
pawn-Human287384 Rocaniraalbo Ambmea ['Ambmea', 'Rocaniraalbo', 'Rocaniraalbo Ambmea'] [] True None
pawn-Human99323 Flubber Flubber ['Flubber', 'Flubber Flubber'] [] True None
pawn-Human1321 Estian Hough ['Estian', 'Estian Hough', 'Hough'] [] True None
pawn-Human352356 Viper Marc�leas ['Marc�leas', 'Viper', 'Viper Marc�leas'] [] False None
pawn-Human106707 Burger Unkown ['Burger', 'Burger Unkown', 'Unkown'] [] True None
pawn-Human414116 Black Hawk ['Black', 'Black Hawk', 'Hawk'] [] False None
pawn-Human106420 Spurius Consus ['Consus', 'Spurius', 'Spurius Consus'] [] False None
pawn-Human382379 Maximus Claudian ['Claudian', 'Maximus', 'Maximus Claudian'] [] False Maximus has a relationship with a colonist. He is Snake's grandfather.
pawn-Human290077 Le Mangum ['Le', 'Le Mangum', 'Mangum'] [] False None
265 letter ['Grasshopper', 'Snake'] Food binge: Grasshopper | Grasshopper is pigging out on food. This happened because of poor mood. The final straw
265 message ['Grasshopper', 'Hyena'] | Grasshopper has been successfully calmed down by Embaixada.
265 message ['Grasshopper'] | Grasshopper is no longer pigging out on food.
265 message ['Snake', 'Rocaniraalbo'] | Snake started a social fight with Rocaniraalbo.
265 message ['Snake', 'Rocaniraalbo'] | Snake and Rocaniraalbo are no longer social fighting.
268 message ['Snake'] | Snake, Warrior is fully healed.
268 letter ['Snake'] Inspired recruitment: Snake | Thanks to high mood, Snake has experienced an inspiration! He will successfully recruit t
268 message ['Rocaniraalbo'] | Rocaniraalbo, Messenger is fully healed.
271 message ['Hyena'] | Embaixada, Thane is no longer inspired (inspired trade).
271 letter ['Snake', 'Hyena'] Disease: Muscle parasites | 2 of your natives have gotten sick from muscle parasites. Ensure you have a med
[... 1794 more characters trimmed]
The ingest works: 556 records and 12 pawns, covering days 117 to 306. I'm fixing three things now: slow def-label lookups, the missing play-log talks, and a leftover name tag.
$ cd /d/Repos/web-apps/estian-tattler && python - <<'EOF'
import re
p='ingest/parse_save.py'; s=open(p,encoding='utf-8').read()
s=s.replace('FACTION_TAG = re.compile(r"\\(\\*Faction=[^)]*\\)|\\(/Faction\\)")','FACTION_TAG = re.compile(r"\\(\\*\\w+(=[^)]*)?\\)|\\(/\\w+\\)")')
s=s.replace(''' for el in root.iter("li"):
if el.findtext("def") == "Human" and el.find("kindDef") is not None and el.find("name") is not None:''',''' for el in root.iter():
if el.findtext("def") == "Human" and el.find("kindDef") is not None and el.find("name") is not None:''')
s=s.replace(''' labels = def_labels(subjects)
return''',''' labels = json.loads(LABELS.read_text(encoding="utf-8")) if LABELS.exists() else {}
if scan_defs:
labels = {**labels, **def_labels(subjects - labels.keys())}
LABELS.write_text(json.dumps(dict(sorted(labels.items())), indent=1, ensure_ascii=False) + "\\n", encoding="utf-8")
return''')
s=s.replace('def parse(save_path):','def parse(save_path, scan_defs=False):')
s=s.replace('''WORKSHOP = os.environ''','''LABELS = Path(__file__).resolve().parent / "def_labels.json"
WORKSHOP = os.environ''')
s=s.replace(''' args = ap.parse_args()
root, game, start, now, pawns, records, labels = parse(args.save)''',''' ap.add_argument("--scan-defs", action="store_true", help="look up def labels in the installed game and mods")
args = ap.parse_args()
root, game, start, now, pawns, records, labels = parse(args.save, args.scan_defs)''')
open(p,'w',encoding='utf-8',newline='\n').write(s)
EOF
grep -n "FACTION_TAG =\|root.iter()\|scan_defs\|LABELS" ingest/parse_save.py
18:LABELS = Path(__file__).resolve().parent / "def_labels.json"
48:FACTION_TAG = re.compile(r"\(\*\w+(=[^)]*)?\)|\(/\w+\)")
103:def parse(save_path, scan_defs=False):
148: for el in root.iter():
163: labels = json.loads(LABELS.read_text(encoding="utf-8")) if LABELS.exists() else {}
164: if scan_defs:
166: LABELS.write_text(json.dumps(dict(sorted(labels.items())), indent=1, ensure_ascii=False) + "
220: root, game, start, now, pawns, records, labels = parse(args.save, args.scan_defs)
D:\Repos\web-apps\estian-tattler\ingest\parse_save.py
The file D:\Repos\web-apps\estian-tattler\ingest\parse_save.py has been updated successfully. (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\ingest\parse_save.py
The file D:\Repos\web-apps\estian-tattler\ingest\parse_save.py has been updated successfully. (file state is current in your context — no need to Read it back)
$ python - <<'EOF'
p='ingest/parse_save.py'; s=open(p,encoding='utf-8').read()
s=s.replace('''def def_labels(wanted):''','''def humanize(def_name):
"""VFEC_MeatDrying -> meat drying, for defs whose mod isn't installed here."""
base = def_name.split("_", 1)[1] if re.match(r"^[A-Z][A-Za-z]{1,7}_", def_name) else def_name
return re.sub(r"(?<=[a-z])(?=[A-Z])", " ", base.replace("_", " ")).lower()
def def_labels(wanted):''')
s=s.replace('''s=subject or r["subjectDef"] or "something")''','''s=subject or humanize(r["subjectDef"] or "something"))''')
open(p,'w',encoding='utf-8',newline='\n').write(s)
EOF
grep -n 'humanize(' ingest/parse_save.py
79:def humanize(def_name):
198: text = verb.format(*(who + ["someone", "someone"]), s=subject or humanize(r["subjectDef"] or "something"))
$ PYTHONIOENCODING=utf-8 python ingest/parse_save.py --scan-defs "C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws"
Command running in background with ID: boye809th. Output is being written to: C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\boye809th.output. You will be notified when it completes. To check interim output, use Read on that file path.
$ cd /c/tmp/wfdocs && sed -n '1,400p' getting-started.md | grep -v "^> For AI agents" | head -300
# Quick start: run your first workflow
Define your first workflow in TypeScript, deploy it, and move a Sanity document through its stages.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
Sanity Workflows moves a document through named stages under rules you deploy. You author a **definition** that names the stages, the work inside each one, and the conditions for moving between them. Deploying the definition makes it available to run. Starting an **instance** runs it against one document.
This quick start deploys a three-stage article review, starts an instance against a document in your dataset, and moves that instance to its final stage. Every step runs from a terminal with the Workflows CLI. No Sanity Studio is involved.
You need:
- Node.js 20.12 or later.
- A Sanity project and a dataset you can write to.
- One published document in that dataset for the workflow to act on. Note its `_id` and its `_type`.
## Step 1: Install the packages
Use Workflows 0.33.0 or later for this quick start. `@sanity/workflow-engine` provides the definition language and runtime. `@sanity/workflow-cli` deploys definitions and drives instances from a terminal. Install both at the same version:
**npm**
```shell
npm install @sanity/workflow-engine @sanity/workflow-cli
```
**pnpm**
```shell
pnpm add @sanity/workflow-engine @sanity/workflow-cli
```
**yarn**
```shell
yarn add @sanity/workflow-engine @sanity/workflow-cli
```
**bun**
```shell
bun add @sanity/workflow-engine @sanity/workflow-cli
```
The CLI reads the token from your Sanity login session. Log in once:
**npm**
```shell
npx sanity@latest login
```
**pnpm**
```shell
pnpm dlx sanity@latest login
```
**yarn**
```shell
yarn dlx sanity@latest login
```
**bun**
```shell
bunx sanity@latest login
```
In CI, set `SANITY_AUTH_TOKEN` instead of logging in. With neither, the CLI stops before it writes anything and reports `No Sanity token found — run `sanity login`, or set SANITY_AUTH_TOKEN.`
## Step 2: Define a three-stage workflow
A definition names the stages a document passes through, the activities inside each stage, and the transitions that move an instance onward. Create `workflows/article-review.ts` with three stages: `drafting`, `review`, and `approved`.
**workflows/article-review.ts**
```typescript
import {
defineAction,
defineActivity,
defin
[... 7448 more characters trimmed]
$ grep -v "^> For AI agents" effects-and-runtimes.md | grep -v "^\*\*\(pnpm\|yarn\|bun\)\*\*"
# Effects and runtimes
Why the engine queues effects instead of running them, and where the runtime lives: the verbs your code calls, and the drainer that delivers queued work.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
A workflow regularly needs to act on the world beyond its own fields: send an email, kick off a build, call an API. Workflows splits that work in two. The engine records what should happen, and a runtime you supply makes it happen.
## Why the engine queues effects instead of running them
The engine never runs that outside work itself. Effects are declared on actions, the only construct that carries work. When the action fires, whether a caller fired it or its `when` condition turned true, the engine queues the effect and records what needs doing. A separate runtime then picks it up, runs it, and reports back.
The engine’s job is evaluating rules and moving instances, and that work has to be repeatable: the same instance and inputs always produce the same decision. Sending an email is not repeatable; it can fail, be slow, or happen twice. Queuing keeps the engine deterministic and testable, and puts the messy part where failure and retries belong.
Queuing writes a row into the instance’s `pendingEffects`. The row carries the effect’s name and its resolved `params`: an effect’s `bindings` are GROQ over the workflow’s state, and the engine evaluates them to concrete JSON at the moment it queues the effect. So a handler receives plain values, and never has to read the workflow back. The stored definition names the effect and never references code. A runtime registers a handler against that name, one handler per name.
## The engine is a library, not a service
Nothing runs in the background watching your content and pushing workflows along. `@sanity/workflow-engine` is a library your process imports: it evaluates rules and commits state when your code calls it, and does nothing at all in between. The runtime is whatever you build around it.
What you call is a small set of verbs. None of them runs unless something calls it.
| Verb | Call it when | Who calls it in practice |
| --- | --- | --- |
| `fireAction` | Someone or something acted: an editor approved, a webhook arrived. Only an action without a `when` condition is caller-fired. | The Studio plugin or your own UI, a webhook receiver, an operator
[... 20179 more characters trimmed]
$ for f in studio-plugin app-sdk; do grep -v "^> For AI agents" $f.md | grep -v "^\*\*\(pnpm\|yarn\|bun\)\*\*" | awk 'BEGIN{skip=0} /^```shell/{c++} {print}' ; done | head -700
<persisted-output>
Output too large (31.2KB). Full output saved to: C:\Users\owner\.claude\projects\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tool-results\bfivrcqpp.txt
Preview (first 2KB):
# Add Workflows to Sanity Studio
Install the Workflows plugin in a Sanity Studio, bind it to your deployed definitions, and put workflows in front of editors.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
The Workflows Studio plugin connects deployed workflow definitions to Sanity Studio. It adds a workflow strip and a Workflows view to the document editor, and a Workflows tool for tracking work across documents.
This guide starts from a Studio with no workflow UI and ends with an editor starting a workflow on a document. For what editors see and do once it is running, read [Workflows in Sanity Studio](https://www.sanity.io/docs/workflows/studio-user-guide).
Before you start, you need:
- At least one deployed workflow definition, and the tag it was deployed under: see [Configure and deploy workflow definitions](https://www.sanity.io/docs/workflows/deploy-definitions).
- Sanity Studio v6.15.0 or later (v6.x).
- React and React DOM 19.2.7 or later.
- `styled-components` 6.4.2 or later.
- `@sanity/sdk` 3.1 or later in the 3.x line. The plugin requires this dependency directly.
- Node.js 20 or later.
> [!NOTE]
> Only the plugin needs a Studio
> Studio 6 is a major version, so a Studio still on v5 needs that upgrade before the plugin installs, and that upgrade is its own piece of work. The engine, the CLI, the App SDK adapter, and the MCP server have no dependency on Sanity Studio, so you can author, deploy, and drive workflows while the Studio upgrade is still ahead of you. The [quick start](https://www.sanity.io/docs/workflows/getting-started) runs end to end without one.
## Install the packages
Install the packages in a Sanity Studio 6.15 or later project in the 6.x line. The plugin reads the definitions you deployed with the Workflows CLI.
...
</persisted-output>
C:\Users\owner\.claude\projects\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tool-results\bfivrcqpp.txt
1
2 # Add Workflows to Sanity Studio
3
4 Install the Workflows plugin in a Sanity Studio, bind it to your deployed definitions, and put workflows in front of editors.
5
6 > [!WARNING]
7 > Early access
8 > Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
9
10
11
12 The Workflows Studio plugin connects deployed workflow definitions to Sanity Studio. It adds a workflow strip and a Workflows view to the document editor, and a Workflows tool for tracking work across documents.
13
14 This guide starts from a Studio with no workflow UI and ends with an editor starting a workflow on a document. For what editors see and do once it is running, read [Workflows in Sanity Studio](https://www.sanity.io/docs/workflows/studio-user-guide).
15
16 Before you start, you need:
17
18 - At least one deployed workflow definition, and the tag it was deployed under: see [Configure and deploy workflow definitions](https://www.sanity.io/docs/workflows/deploy-definitions).
19 - Sanity Studio v6.15.0 or later (v6.x).
20 - React and React DOM 19.2.7 or later.
21 - `styled-components` 6.4.2 or later.
22 - `@sanity/sdk` 3.1 or later in the 3.x line. The plugin requires this dependency directly.
23 - Node.js 20 or later.
24
25 > [!NOTE]
26 > Only the plugin needs a Studio
27 > Studio 6 is a major version, so a Studio still on v5 needs that upgrade before the plugin installs, and that upgrade is its own piece of work. The engine, the CLI, the App SDK adapter, and the MCP server have no dependency on Sanity Studio, so you can author, deploy, and drive workflows while the Studio upgrade is still ahead of you. The [quick start](https://www.sanity.io/docs/workflows/getting-started) runs end to end without one.
28
29 ## Install the packages
30
31 Install the packages in a Sanity Studio 6.15 or later project in the 6.x line. The plugin reads the definitions you deployed with the Workflows CLI.
32
33 Install every `@sanity/workflow-*` package at the same version. The command includes the plugin’s required Workflows packages. For an existing installation, follow [the compatibility and upgrade instructions](https://www.sanity.io/docs/workflows/upgrade).
34
35 SDK 3.1.0 can resolve `@sanity/mutate` 0.18.1, which can leave document reads pending with Sanity client 8. Before installing, merge this override into your application’s root package-manager configuration:
36
37 **package.json
[... 32242 more characters trimmed]
I've read the Workflows docs, and the engine fits the plan: stories move through desk stages, and effects call Claude and GitHub. I'm checking the rebuilt data next, then writing the schema.
$ cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/boye809th.output"; cd /d/Repos/web-apps/estian-tattler && cat ingest/def_labels.json | head -30; PYTHONIOENCODING=utf-8 python -c "
import json
docs=[json.loads(l) for l in open('data/tattler.ndjson',encoding='utf-8')]
print(docs[0])
for d in docs:
if d['_type']=='record' and d['kind'] in ('tale','talk') and d['def'] not in ('PlayedGame','WalkedNaked','Wounded'): print(d['colonyDay'], d['kind'], d['text'])
" | head -80
cat: ingest/def_labels.json: No such file or directory
{'_id': 'colony', '_type': 'colony', 'name': 'Tribe of Estian', 'gameVersion': '1.5.4297 rev1079', 'modCount': 815, 'currentDay': 306, 'firstRecordDay': 117, 'recordCount': 556}
119 tale Snake Rato came down with heatstroke.
119 tale Hyena Embaixada came down with heatstroke.
119 tale Reiraborvas 'Grasshopper' Canga finished researching VFEC_MeatDrying.
120 tale Hyena Embaixada finished building ASF_Granary.
123 tale Hyena Embaixada visited the grave of Flubber Flubber.
123 tale Hyena Embaixada finished building ASF_Granary.
125 tale Reiraborvas 'Grasshopper' Canga attended a party thrown by Reiraborvas 'Grasshopper' Canga.
125 tale Rocaniraalbo Ambmea attended a party thrown by Reiraborvas 'Grasshopper' Canga.
125 tale Hyena Embaixada attended a party thrown by Reiraborvas 'Grasshopper' Canga.
125 tale Snake Rato attended a party thrown by Reiraborvas 'Grasshopper' Canga.
125 tale Reiraborvas 'Grasshopper' Canga became a master of Intellectual.
125 tale Rocaniraalbo Ambmea finished building rustic cooking table.
126 tale Hyena Embaixada saw an eclipse.
128 tale Rocaniraalbo Ambmea finished crafting VAE_Apparel_PeltCoat.
129 tale Reiraborvas 'Grasshopper' Canga finished researching VFEC_TemperatureControl.
131 tale Hyena Embaixada got into a fist fight with Reiraborvas 'Grasshopper' Canga.
132 tale Snake Rato married Reiraborvas 'Grasshopper' Rato.
133 tale Reiraborvas 'Grasshopper' Rato finished researching passive cooler.
136 tale Snake Rato visited the grave of Estian Hough.
137 tale Hyena Embaixada visited the grave of Estian Hough.
138 tale Hyena Embaixada finished crafting VAE_Apparel_PeltCoat.
138 tale Hyena Embaixada traded with Viper Marcóleas.
143 tale Reiraborvas 'Grasshopper' Rato finished researching VFEC_LegionaryArmaments.
149 tale Rocaniraalbo Ambmea visited the grave of Burger Unkown.
150 tale Hyena Embaixada attended a party thrown by Reiraborvas 'Grasshopper' Rato.
150 tale Rocaniraalbo Ambmea attended a party thrown by Reiraborvas 'Grasshopper' Rato.
150 tale Snake Rato attended a party thrown by Reiraborvas 'Grasshopper' Rato.
150 tale Reiraborvas 'Grasshopper' Rato attended a party thrown by Reiraborvas 'Grasshopper' Rato.
152 tale Reiraborvas 'Grasshopper' Rato finished researching VFEC_DramaAndComedy.
154 tale Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
155 tale Reiraborvas 'Grasshopper' Rato finished researching pemmican.
158 tale H
[... 3282 more characters trimmed]
Before the schema, I'm fixing the pawn name so it comes from the latest snapshot instead of the first one seen.
$ cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/boye809th.output"; cd /d/Repos/web-apps/estian-tattler && sed -n '176,190p' ingest/parse_save.py
631 records ({'tale': 354, 'message': 149, 'letter': 53, 'talk': 75}), 17 pawns, days 117-306, 17/55 def labels -> D:\Repos\web-apps\estian-tattler\data\tattler.ndjson
[exited with code 0]
def pawn_doc(pid, p):
latest = p["names"][-1]
snap = p["snapshot"]
doc = {"_id": f"pawn-{pid}", "_type": "pawn", "thingId": pid, "name": full(latest), "shortName": short(latest),
"aliases": sorted({full(n) for n in p["names"]} | {x for n in p["names"] for x in n.values() if x}),
"formerNames": [full(n) for n in p["names"][:-1] if full(n) != full(latest)]}
if snap is not None:
doc.update({"gender": snap.findtext("gender"), "age": int(snap.findtext("age") or 0) or None,
"relationNote": clean(snap.findtext("relationInfo")) or None,
"everColonist": snap.findtext("everBeenColonistOrTameAnimal") == "True"})
return doc
def record_doc(r, pawns, labels):
doc = {"_type": "record", "kind": r["kind"], "sourceId": r["sourceId"], "def": r["def"],
D:\Repos\web-apps\estian-tattler\ingest\parse_save.py
The file D:\Repos\web-apps\estian-tattler\ingest\parse_save.py has been updated successfully. (file state is current in your context — no need to Read it back)
$ PYTHONIOENCODING=utf-8 python ingest/parse_save.py "C:/Users/owner/AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Saves/Tribe of Estian.rws" && PYTHONIOENCODING=utf-8 python -c "
import json
docs=[json.loads(l) for l in open('data/tattler.ndjson',encoding='utf-8')]
for d in docs:
if d['_type']=='pawn': print(d['_id'], d['name'], d.get('formerNames'), d.get('relationNote'), d.get('everColonist'))
for d in docs:
if d['_type']=='record' and d['def'] in ('Breakup','Marriage','SocialFight','DeepTalk','Slight'): print(d['colonyDay'], d['text'])
"; ls -la ingest/
631 records ({'tale': 354, 'message': 149, 'letter': 53, 'talk': 75}), 17 pawns, days 117-306, 17/55 def labels -> D:\Repos\web-apps\estian-tattler\data\tattler.ndjson
pawn-Human250142 Reiraborvas 'Grasshopper' Canga ["Reiraborvas 'Grasshopper' Rato"] Grasshopper has a relationship with a colonist. She is Snake's ex-wife. True
pawn-Human147640 Hyena Embaixada [] None True
pawn-Human144483 Snake Rato [] Snake has a relationship with a colonist. He is Grasshopper's ex-husband. True
pawn-Human287384 Rocaniraalbo Ambmea [] None True
pawn-Human99323 Flubber Flubber [] None True
pawn-Human1321 Estian Hough [] None True
pawn-Human352356 Viper Marcóleas [] None False
pawn-Human106707 Burger Unkown [] None True
pawn-Human414116 Black Hawk [] None False
pawn-Human106420 Spurius Consus [] None False
pawn-Human382379 Maximus Claudian [] Maximus has a relationship with a colonist. He is Snake's grandfather. False
pawn-Human290077 Le Mangum [] None False
pawn-Human331984 Charites Decrescendo [] None None
pawn-Human568581 Aquila Summanus [] None None
pawn-Human704885 Charango Paphianne [] None None
pawn-Human141555 Iola 'Smalls' Schaefer [] None None
pawn-Human113017 Marcellina Triarius [] None None
131 Hyena Embaixada got into a fist fight with Reiraborvas 'Grasshopper' Canga.
132 Snake Rato married Reiraborvas 'Grasshopper' Rato.
252 Snake Rato broke up with Reiraborvas 'Grasshopper' Canga.
265 Snake Rato got into a fist fight with Rocaniraalbo Ambmea.
305 Iola 'Smalls' Schaefer had a deep talk with Aquila Summanus.
305 Reiraborvas 'Grasshopper' Canga insulted Aquila Summanus.
305 Charango Paphianne insulted Marcellina Triarius.
305 Iola 'Smalls' Schaefer had a deep talk with Charites Decrescendo.
305 Aquila Summanus insulted Marcellina Triarius.
305 Iola 'Smalls' Schaefer had a deep talk with Charites Decrescendo.
304 Charango Paphianne insulted Aquila Summanus.
total 13
drwxr-xr-x 1 owner 197609 0 Sep 25 21:13 .
drwxr-xr-x 1 owner 197609 0 Sep 25 21:10 ..
-rw-r--r-- 1 owner 197609 635 Sep 25 21:12 def_labels.json
-rw-r--r-- 1 owner 197609 11575 Sep 25 21:13 parse_save.py
Data looks right now: Grasshopper is back to Canga after the day-252 breakup, and 631 records. Writing the Sanity schema.
$ mkdir -p /d/Repos/web-apps/estian-tattler/studio/schemaTypes /d/Repos/web-apps/estian-tattler/studio/components && cd /d/Repos/web-apps/estian-tattler/studio/schemaTypes && cat > colony.ts <<'EOF'
import {defineField, defineType} from 'sanity'
export const colony = defineType({
name: 'colony',
title: 'Colony',
type: 'document',
readOnly: true,
fields: [
defineField({name: 'name', type: 'string'}),
defineField({name: 'gameVersion', type: 'string'}),
defineField({name: 'modCount', type: 'number'}),
defineField({name: 'currentDay', type: 'number', description: 'Days since landing when the save was made.'}),
defineField({
name: 'firstRecordDay',
type: 'number',
description: 'The save forgets old tales and letters. Nothing before this day can be proven.',
}),
defineField({name: 'recordCount', type: 'number'}),
],
})
EOF
cat > pawn.ts <<'EOF'
import {defineArrayMember, defineField, defineType} from 'sanity'
export const pawn = defineType({
name: 'pawn',
title: 'Pawn',
type: 'document',
readOnly: true,
fields: [
defineField({name: 'name', type: 'string', description: 'As of the last record that mentions them.'}),
defineField({name: 'shortName', type: 'string'}),
defineField({
name: 'formerNames',
type: 'array',
of: [defineArrayMember({type: 'string'})],
description: 'Other names the save has on file, usually from a marriage.',
}),
defineField({name: 'aliases', type: 'array', of: [defineArrayMember({type: 'string'})]}),
defineField({name: 'gender', type: 'string'}),
defineField({name: 'age', type: 'number'}),
defineField({name: 'relationNote', type: 'string'}),
defineField({name: 'everColonist', type: 'boolean'}),
defineField({name: 'thingId', type: 'string'}),
],
preview: {
select: {title: 'name', note: 'relationNote', colonist: 'everColonist'},
prepare: ({title, note, colonist}) => ({title, subtitle: note ?? (colonist ? 'Colonist' : 'Outsider')}),
},
})
EOF
cat > record.ts <<'EOF'
import {defineArrayMember, defineField, defineType} from 'sanity'
// One thing the save file can prove happened. Written by ingest/parse_save.py, never by hand.
export const record = defineType({
name: 'record',
title: 'Record',
type: 'document',
readOnly: true,
fields: [
defineField({
name: 'kind',
type: 'string',
options: {list: ['tale', 'letter', 'message', 'talk']},
description: 'Tales are what pawns remember, letters and messages are what the game told the player, talks come from the play log.',
}),
defineField({name: 'text', type: 'text', rows: 3}),
defineField({name: 'label', type: 'string'}),
defineField({name: 'colonyDay', type: 'number'}),
defineField({name: 'hour', type: 'number'}),
defineField({name: 'pawns', type: 'array', of: [defineArrayMember({type: 'reference', to: [{type: 'pawn'}]})]}),
defineField({name: 'def', type: 'string', description: "The game's def for the tale, letter or interaction."}),
defineField({name: 'subjectDef', type: 'string'}),
defineField({name: 'subjectLabel', type: 'string'}),
defineField({name: 'letterClass', type: 'string'}),
defineField({name: 'tick', type: 'number'}),
defineField({name: 'sourceId', type: 'string', description: 'ID of the entry inside the save.'}),
],
orderings: [{title: 'Colony day', name: 'day', by: [{field: 'tick', direction: 'asc'}]}],
preview: {
select: {text: 'text', label: 'label', day: 'colonyDay', kind: 'kind'},
prepare: ({text, label, day, kind}) => ({title: label || text, subtitle: `Day ${day} · ${kind}`}),
},
})
EOF
ls
colony.ts
index.ts
pawn.ts
record.ts
$ cat > story.ts <<'EOF'
import {defineArrayMember, defineField, defineType} from 'sanity'
import {ClaimAnnotation, AsideAnnotation} from '../components/annotations'
export const SECTIONS = ['Front page', 'Hearts', 'Science', 'Brawls', 'Commerce', 'Weather', 'Graves']
export const story = defineType({
name: 'story',
title: 'Story',
type: 'document',
fields: [
defineField({name: 'headline', type: 'string', validation: (rule) => rule.required().max(90)}),
defineField({name: 'dek', type: 'string', description: 'The line under the headline.'}),
defineField({name: 'section', type: 'string', options: {list: SECTIONS}}),
defineField({
name: 'pitch',
type: 'text',
rows: 2,
description: 'What the desk wants from the reporter.',
}),
defineField({
name: 'leads',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'record'}]})],
description: 'The records the pitch starts from. The reporter can dig up more.',
}),
defineField({
name: 'body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [{title: 'Normal', value: 'normal'}],
lists: [],
marks: {
decorators: [],
annotations: [
{
name: 'claim',
type: 'object',
title: 'Claim',
components: {annotation: ClaimAnnotation},
fields: [
defineField({
name: 'records',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'record'}]})],
validation: (rule) => rule.min(1),
}),
],
},
{
name: 'aside',
type: 'object',
title: 'Aside',
description: "The paper's opinion. Can't name a pawn or carry a number.",
components: {annotation: AsideAnnotation},
fields: [defineField({name: 'note', type: 'string'})],
},
],
},
}),
],
}),
defineField({name: 'byline', type: 'string'}),
defineField({
name: 'factCheck',
type: 'object',
readOnly: true,
description: 'Written by the fact-check desk. Every sentence needs a receipt or an aside mark.',
fields: [
defineField({name: 'passed', type: 'boolean'}),
defineField({name: 'checkedAt', type: 'datetime'}),
defineField({name: 'claims', type: 'number'}),
defineField({name: 'asides', type: 'number'}),
defineField({
name: 'problems',
type: 'array',
of: [
defineArrayMember({
type: 'object',
name: 'problem',
fields: [
defineField({name: 'text', type: 'string'}),
defineField({name: 'reason', type: 'string'}),
],
preview: {select: {title: 'reason', subtitle: 'text'}},
}),
],
}),
],
}),
defineField({name: 'edition', type: 'number', readOnly: true}),
defineField({name: 'printedAt', type: 'datetime', readOnly: true}),
],
preview: {
select: {title: 'headline', section: 'section', passed: 'factCheck.passed', printed: 'printedAt'},
prepare: ({title, section, passed, printed}) => ({
title,
subtitle: [section, printed ? 'printed' : passed === false ? 'failed fact-check' : null]
.filter(Boolean)
.join(' · '),
}),
},
})
EOF
cat > index.ts <<'EOF'
import {colony} from './colony'
import {pawn} from './pawn'
import {record} from './record'
import {story} from './story'
export const schemaTypes = [colony, pawn, record, story]
EOF
(Bash completed with no output)
Now the fact-check desk. It's one pure function, shared by the Studio validation and the workflow effect.
D:\Repos\web-apps\estian-tattler\studio\lib\factcheck.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\lib\factcheck.ts (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/studio && grep -o "export declare interface BlockAnnotationProps[^}]*}" -r node_modules/sanity/lib/_chunks-dts/*.d.ts | head -3; grep -rn "interface BlockAnnotationProps" node_modules/sanity/lib --include=*.d.ts | head
grep: node_modules/sanity/lib/_chunks-dts/*.d.ts: No such file or directory
node_modules/sanity/lib/useVirtualizerScrollInstance-CvJtR-hY.d.ts:1492:interface BlockAnnotationProps {
$ sed -n '1485,1540p' node_modules/sanity/lib/useVirtualizerScrollInstance-CvJtR-hY.d.ts
* </Dialog>
* )}
* </>
* )
* }
* ```
* */
interface BlockAnnotationProps {
/**
* Boundary element of the floating toolbar element.
*/
__unstable_floatingBoundary: HTMLElement | null;
/**
* Boundary element where the text for this annotation appears.
*/
__unstable_referenceBoundary: HTMLElement | null;
/**
* DOM element for the annotated text.
*/
__unstable_referenceElement: HTMLElement | null;
/**
* Wether the annotated text node has editor focus.
* @remarks differs from `focused` which is wether the annotation object has form focus.
*/
__unstable_textElementFocus?: boolean;
/**
* The input form for the annotation object.
* @remarks If you wrap this in something, you must make sure to put `contentEditable={false}` on the root container.
* Otherwise the editor will think content is part of the editable text and will error.
*/
children: ReactNode;
/**
* If the editor form for this annotation object currently have form focus.
*/
focused: boolean;
/**
* Markers (meta data) connected to this annotation.
* @deprecated - use `renderBlock` and `renderInlineBlock` interfaces instead
*/
markers: PortableTextMarker[];
/**
* Closes the editing form connected to this annotation.
*/
onClose: () => void;
/**
* Opens the editing form connected to this annotation.
*/
onOpen: () => void;
/**
* Focus a form node in the object for this annotation.
* @param path - the relative path to the form node to put focus on.
*/
onPathFocus: (path: Path) => void;
/**
* Removes the annotation object from the text.
*/
$ sed -n '1540,1600p' node_modules/sanity/lib/useVirtualizerScrollInstance-CvJtR-hY.d.ts | grep -v "^\s*\*\|/\*\*"
onRemove: () => void;
open: boolean;
parentSchemaType: SchemaType;
path: Path;
presence: FormNodePresence[];
readOnly: boolean;
renderAnnotation?: RenderAnnotationCallback;
renderBlock?: RenderBlockCallback;
renderDefault: (props: BlockAnnotationProps) => React.JSX.Element;
renderField: RenderFieldCallback;
renderInlineBlock?: RenderBlockCallback;
renderInput: RenderInputCallback;
renderItem: RenderArrayOfObjectsItemCallback;
renderPreview: RenderPreviewCallback;
schemaType: ObjectSchemaType & {
i18nTitleKey?: string;
};
D:\Repos\web-apps\estian-tattler\studio\components\annotations.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\components\annotations.tsx (file state is current in your context — no need to Read it back)
$ npm i @sanity/ui 2>&1 | tail -1 && npx tsc --noEmit -p . 2>&1 | head -20 && npx sanity dataset import ../data/tattler.ndjson production --replace 2>&1 | tail -5
Run `npm audit` for details.
components/annotations.tsx(27,14): error TS2322: Type 'number' is not assignable to type 'undefined'.
components/annotations.tsx(39,6): error TS2604: JSX element type 'Tooltip' does not have any construct or call signatures.
[32m✔[39m [100%] Importing documents (4.34s)
- [0%] Strengthening references (0.00s)
[32m✔[39m [100%] Strengthening references (1.08s)
Done! Imported 649 documents to dataset "production"
The 649 documents are imported into Sanity. I'm fixing two type errors in the Studio annotation components.
$ npm ls @sanity/ui | head; grep -n "Tooltip" node_modules/@sanity/ui/dist/index.d.ts | head -5
estian-tattler@1.0.0 D:\Repos\web-apps\estian-tattler\studio
+-- @sanity/ui@4.2.4
+-- @sanity/vision@6.16.0
| `-- @sanity/ui@4.2.4 deduped
`-- sanity@6.16.0
+-- @sanity/access-ui@6.16.0
| `-- @sanity/ui@4.2.4 deduped
`-- @sanity/ui@4.2.4 deduped
1531:export declare const Tooltip: never;
1533:export declare const TooltipDelayGroupContext: never;
1535:export type TooltipDelayGroupContextValue = never;
1537:export declare const TooltipDelayGroupProvider: never;
1539:export type TooltipDelayGroupProviderProps = never;
$ sed -n '1515,1535p' node_modules/@sanity/ui/dist/index.d.ts; grep -n "gap\b\|gap?:" node_modules/@sanity/ui/dist/index.d.ts | head -5; ls node_modules/@sanity/ui/dist/
export type PopoverWidth = never;
/** @deprecated Moved to `@sanity/ui/toast` */
export declare const Toast: never;
/** @deprecated Moved to `@sanity/ui/toast` */
export type ToastContextValue = never;
/** @deprecated Moved to `@sanity/ui/toast` */
export type ToastParams = never;
/** @deprecated Moved to `@sanity/ui/toast` */
export type ToastProps = never;
/** @deprecated Moved to `@sanity/ui/toast` */
export declare const ToastProvider: never;
/** @deprecated Moved to `@sanity/ui/toast` */
export type ToastProviderProps = never;
/** @deprecated Moved to `@sanity/ui/toast` */
export declare const useToast: never;
/** @deprecated Moved to `@sanity/ui/tooltip` */
export declare const Tooltip: never;
/** @deprecated Moved to `@sanity/ui/tooltip` */
export declare const TooltipDelayGroupContext: never;
/** @deprecated Moved to `@sanity/ui/tooltip` */
export type TooltipDelayGroupContextValue = never;
231: gap?: number | number[];
235: * @deprecated Use `gap` instead.
333: * @deprecated Use `gap` instead.
337: gap?: number | number[];
378: gap?: number | number[];
_system-CTe2lb5l.d.ts
_system-CTe2lb5l.d.ts.map
arrow-CZ-Lw1QO.js
arrow-CZ-Lw1QO.js.map
autocomplete.d.ts
autocomplete.d.ts.map
autocomplete.js
autocomplete.js.map
breadcrumbs.d.ts
breadcrumbs.d.ts.map
breadcrumbs.js
breadcrumbs.js.map
button-Wii-24Xu.js
button-Wii-24Xu.js.map
button-dnqMFkWg.d.ts
button-dnqMFkWg.d.ts.map
card-C0a0wQTc.d.ts
card-C0a0wQTc.d.ts.map
code.d.ts
code.d.ts.map
code.js
code.js.map
codeFontStyle-84SMhFtI.js
codeFontStyle-84SMhFtI.js.map
component-DVlwaWB-.d.ts
component-DVlwaWB-.d.ts.map
element-BYgCE_iK.js
element-BYgCE_iK.js.map
factory-BzXTX8_Q.js
factory-BzXTX8_Q.js.map
flex-CpiAc-4D.js
flex-CpiAc-4D.js.map
font-EtfxKPUz.d.ts
font-EtfxKPUz.d.ts.map
getElementRef-Jw7gY0eV.js
getElementRef-Jw7gY0eV.js.map
getScopedTheme-C-sZsyAz.js
getScopedTheme-C-sZsyAz.js.map
getTheme_v2-y09KqtEb.js
getTheme_v2-y09KqtEb.js.map
grid-BVZEj_Xe.js
grid-BVZEj_Xe.js.map
helpers-C8xxXAQC.js
helpers-C8xxXAQC.js.map
hotkeys-DEMStMQ5.js
hotkeys-DEMStMQ5.js.map
index.d.ts
index.d.ts.map
index.js
index.js.map
layer-B0tlPeLT.js
layer-B0tlPeLT.js.map
layer-ChlP9Ik6.d.ts
layer-ChlP9Ik6.d.ts.map
menu.d.ts
menu.d.ts.map
menu.js
menu.js.map
popover-CMlQeKn0.d.ts
popover-CMlQeKn0.d.ts.map
popover-DR6UQQvq.js
popover-DR6UQQvq.js.map
popover.d.ts
popover.js
refractor-CC48DwHi.js
refractor-CC48DwHi.js.map
responsiveFont-CilwaORG.js
responsiveFont-CilwaORG.js.map
rgba-BSYaEUW6.d.t
[... 533 more characters trimmed]
$ sed -i "s#import {Box, Stack, Text, Tooltip} from '@sanity/ui'#import {Box, Stack, Text} from '@sanity/ui'\nimport {Tooltip} from '@sanity/ui/tooltip'#; s#<Stack space={3}>#<Stack gap={3}>#" components/annotations.tsx && npx tsc --noEmit -p . && echo TSC OK
TSC OK
Studio typechecks. Next is the story-desk workflow definition, and I'm checking the engine's field types first.
$ cd /d/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/workflow-engine/dist && grep -n "type: '\(string\|number\|boolean\|subject\|text\|datetime\|object\|array\|reference\|doc.ref\)'" define.d.ts | head -30; grep -n "FieldKind\|type FieldType\|declare type .*Field.*=" define.d.ts | head -30
25:declare type ActionFields<TOp, TGroup, TEffect> = {
202:declare type ActivityFields<TField, TAction, TTarget, TGroup> = {
279:declare type AuthoringFieldEntry =
290:declare type AuthoringFieldRef = v.InferOutput<typeof AuthoringFieldRefSchema>;
453:declare type AuthoringRawFieldEntry = FieldEntryFields<
732:declare type EffectFields<TRetry> = {
789:declare type EffectRetryFields = {
857:declare type FieldBase<TEditable, TGroup> = {
891:declare type FieldEntry = FieldEntryFields<Editable, string[]>;
894:declare type FieldEntryFields<TEditable, TGroup> = FieldBase<
975:declare type FieldOp = FieldMutationOp<StoredFieldRef>;
978:declare type FieldReadExpr = {
1034:declare type FieldSource = FieldSourceInternal;
1037:declare type FieldSourceInternal =
1049:declare type FieldValueKind = (typeof FIELD_VALUE_KINDS)[number];
1281:declare type NotesField = FieldBase<AuthoringEditable, GroupMembership> & {
1407:declare type StageFields<TField, TActivity, TTransition, TGuard, TEditable> = {
1445:declare type StartFields = {
1469:declare type StoredFieldRef = v.InferOutput<typeof StoredFieldRefSchema>;
1604:declare type TodoListField = FieldBase<AuthoringEditable, GroupMembership> & {
1623:declare type TransitionFields = {
2292:declare type WorkflowFields<TField, TStage, TStart> = {
$ sed -n '894,975p;1034,1060p;1623,1660p' define.d.ts; grep -n "FIELD_VALUE_KINDS" -A 25 define.d.ts | head -40
declare type FieldEntryFields<TEditable, TGroup> = FieldBase<
TEditable,
TGroup
> & {
type: FieldValueKind;
options?: ChoiceOptions | undefined;
validation?: ScalarValidation | undefined;
types?: string[] | undefined;
/** Non-empty assignment eligibility constraint. User roles apply aliases; collective roles match literally. */
roles?: string[] | undefined;
fields?: FieldShape[] | undefined;
of?: FieldShape[] | undefined;
};
/**
* A field mutation targeting a declared field. Resolved values must satisfy
* that field's shape, choices, validation, and assignment constraints.
*
* `field.setIfMissing` supports nullable fields only. If a value exists, it
* leaves the value unchanged and records no `opApplied` history event.
* `field.inc` and `field.dec` require an initialized `number` field; their
* delta defaults to `1`. Both the delta and resulting value must be finite,
* and the result must satisfy the field's validation bounds.
*
* `field.append` adds one valid list member. `field.updateWhere` accepts only
* `array` fields and merges an object into matching rows. The merge cannot
* write `_key` or `_type`, and each resulting row must satisfy its declared
* shape. `field.removeWhere` supports list fields. See {@link Op} for row
* selection and history behavior.
*/
declare type FieldMutationOp<
TTarget extends {
field: string;
},
> =
| {
type: "field.set";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.setIfMissing";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.unset";
target: TTarget;
}
| {
type: "field.append";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.inc";
target: TTarget;
value?: ValueExpr | undefined;
}
| {
type: "field.dec";
target: TTarget;
value?: ValueExpr | undefined;
}
| {
type: "field.updateWhere";
target: TTarget;
where: Condition;
value: ValueExpr;
}
| {
type: "field.removeWhere";
target: TTarget;
where: Condition;
};
/**
* A {@link FieldMutationOp} whose target scope is explicit. Actions can write
* fields in their activity, stage, or workflow. Effect completions accept only
* workflow- or stage-scoped field operations, never activity status changes.
*/
declare type FieldOp = FieldMutationOp<StoredFieldRef>;
declare type FieldSource = FieldSourceInternal;
/** @inline */
decla
[... 2965 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/workflow-engine && grep -n -i "stage visit\|re-enter\|reentry\|re-entry\|stage-scoped\|stage scope" DATAMODEL.md dist/define.d.ts | head -30; sed -n '25,80p' dist/define.d.ts
DATAMODEL.md:9:| `temp.system.guard` | `compileGuards` (`src/core/guards.ts`) | full-body refresh on stage re-entry; revision-conditional delete on retract |
DATAMODEL.md:37: including the recovery paths (expired-lease sweep, loop-back re-entry
DATAMODEL.md:1054:transitions reading `$effectStatus` — but that re-enters a stage and queues a
dist/define.d.ts:870: * on each stage visit. {@link FieldSource} controls initialization;
dist/define.d.ts:973: * workflow- or stage-scoped field operations, never activity status changes.
dist/define.d.ts:1385: * The fields, activities, guards, and outgoing routes for one stage visit.
dist/define.d.ts:1494: * Missing or duplicate identities reject the spawn. Re-entry adopts matching
declare type ActionFields<TOp, TGroup, TEffect> = {
name: string;
semantics?: ActionSemantic[] | undefined;
title?: string | undefined;
description?: string | undefined;
group?: TGroup | undefined;
when?: string | undefined;
filter?: string | undefined;
params?: ActionParam[] | undefined;
ops?: TOp[] | undefined;
effects?: TEffect[] | undefined;
spawn?: Subworkflows | undefined;
};
/**
* Caller-supplied params declared on an action, validated before running ops
* or queuing effects: a missing required param throws
* `ActionParamsInvalidError` and the action does not commit. Resolved values
* feed `ValueExpr.param` lookups.
*
* @interface
*/
declare type ActionParam = v.InferOutput<typeof ActionParamSchema>;
declare const ActionParamSchema: v.SchemaWithPipe<
readonly [
v.StrictObjectSchema<
{
readonly type: v.PicklistSchema<
readonly [
"string",
"number",
"boolean",
"url",
"dateTime",
"actor",
"doc.ref",
"doc.refs",
"json",
],
string
>;
readonly name: v.SchemaWithPipe<
readonly [
v.StringSchema<undefined>,
v.MinLengthAction<string, 1, "must be a non-empty string">,
]
>;
readonly title: v.OptionalSchema<v.StringSchema<undefined>, undefined>;
readonly description: v.OptionalSchema<
v.StringSchema<undefined>,
undefined
>;
readonly required: v.OptionalSchema<
v.BooleanSchema<undefined>,
undefined
$ ls /c/tmp/wfdocs/; grep -l "defineEffect" /c/tmp/wfdocs/*.md
app-sdk.md
cli-reference.md
cookbook-ai-content-pipeline.md
effects-and-runtimes.md
getting-started.md
limits.md
sanity-functions.md
studio-plugin.md
/c/tmp/wfdocs/effects-and-runtimes.md
$ cat /c/tmp/wfdocs/cookbook-ai-content-pipeline.md | head -400
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Cookbook: AI content pipeline
An AI content pipeline built on Workflows: effect handlers call generation APIs while editors approve results through workflow actions.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
An agent drafts, automated checks run, and a person steps in only when a check flags the piece. The pipeline runs in five stages:

1. **Drafting**: a trigger queues an AI effect (Agent Actions `generate`) that writes the [draft](https://www.sanity.io/docs/content-lake/drafts). A second trigger marks the work done when the run completes.
2. **Checks**: a trigger queues check effects (Agent Actions `prompt`) for brand voice, SEO, links, and facts.
3. **Verification**: entered only when a check flagged the piece. A person approves or sends it back. Clean pieces skip straight to publishing.
4. **Publishing**: triggers publish the [document](https://www.sanity.io/docs/content-lake/documents) and then queue an idempotent notification outbox record.
5. **Published**: terminal. Arriving here completes the instance; a terminal stage declares no work.
## Before you get started
- **Packages**: `@sanity/workflow-engine`, [@sanity/client](https://www.sanity.io/docs/apis-and-sdks/js-client-getting-started) (v7.8.2+, the Prompt action needs it), and `@sanity/functions` + `@sanity/blueprints` for the runtime.
- **A deployed schema and its schemaId**: Agent Actions write through your schema ([schema deployment](https://www.sanity.io/docs/apis-and-sdks/schema-deployment)).
- **Tokens and clients**: an editor token for the content [dataset](https://www.sanity.io/docs/content-lake/datasets) (Agent Actions and publishing), plus the engine’s own client.
- Datasets: the content dataset the articles live in, and usually a separate workflows dataset for the engine’s instances. A single dataset works too. The subject is a [global document reference](https://www.sanity.io/docs/workflows/global-doc
[... 12068 more characters trimmed]
$ sed -n '400,700p' /c/tmp/wfdocs/cookbook-ai-content-pipeline.md; grep -n "drainEffects\|createEngine\|handlers" /c/tmp/wfdocs/effects-and-runtimes.md | head -30
type PublishClient = {
action: (action: PublishAction) => Promise<unknown>
fetch: <Result>(query: string, params: Record<string, string>) => Promise<Result>
}
function fetchPublishState(client: PublishClient, id: string): Promise<PublishState> {
return client.fetch(
`{
"draft": *[_id == $draftId][0]{_id, _rev},
"published": *[_id == $publishedId][0]{_id}
}`,
{draftId: `drafts.${id}`, publishedId: id},
)
}
export async function publishDocument(client: PublishClient, id: string): Promise<void> {
const before = await fetchPublishState(client, id)
if (before.draft == null) {
if (before.published != null) return
throw new Error(`no draft exists for ${id}`)
}
try {
await client.action({
actionType: 'sanity.action.document.publish',
publishedId: id,
draftId: before.draft._id,
ifDraftRevisionId: before.draft._rev,
})
} catch (error) {
const after = await fetchPublishState(client, id)
if (after.draft == null && after.published != null) return
throw error
}
}
export const publishDoc: EffectHandler = async (params) => {
await publishDocument(content, subjectId(params))
}
```
### Notification outbox
```typescript
import type {EffectHandler} from '@sanity/workflow-engine'
import {content, subjectId} from './content'
export const notify: EffectHandler = async (params, ctx) => {
const id = subjectId(params)
await content.createIfNotExists({
_id: `workflow-notification.${ctx.effectKey}`,
_type: 'workflowNotification',
kind: 'articlePublished',
subjectId: id,
createdAt: new Date().toISOString(),
})
ctx.log(`queued publication notification for ${id}`)
}
```
### Map effects to handlers
```typescript
import type {EffectHandler} from '@sanity/workflow-engine'
import {aiDraft} from './ai-draft'
import {brandVoice, factCheck, linkCheck, seo} from './checks'
import {notify} from './notify'
import {publishDoc} from './publish-doc'
export {content} from './content'
export const effectHandlers: Record<string, EffectHandler> = {
'ai-draft': aiDraft,
'brand-voice': brandVoice,
seo,
'link-check': linkCheck,
'fact-check': factCheck,
'publish-doc': publishDoc,
notify,
}
```
Learn more in the [Generate](https://www.sanity.io/docs/agent-actions/generate-cheatsheet) and [Prompt](https://www.sanity.io/docs/agent-actions/prompt-quickstart) docs (Prompt is experimental), and in the [Actions API](https://www.sanity.io/docs/content-lake/disp
[... 9540 more characters trimmed]
$ grep -n "field.inc\|\"field.set\"\|scope\|EffectOutcome\|HandlerResult" dist/define.d.ts dist/index.d.ts | head -40
dist/define.d.ts:12: * disabled); on a `when` action it composes: `filter` scopes whether the
dist/define.d.ts:180: * spawn lives on an action; an activity contributes scoped `fields`
dist/define.d.ts:183: * deep-link, render-only and never gating). All in-scope activities are
dist/define.d.ts:277: * See {@link FieldEntry} for scope, required-input, and field-type constraints.
dist/define.d.ts:285: * A field reference with `scope` optional; desugar resolves it lexically
dist/define.d.ts:294: readonly scope: v.OptionalSchema<
dist/define.d.ts:521: * A raw GROQ string evaluated over the rendered scope: the engine-bound
dist/define.d.ts:533: * Every variable the engine binds for the RENDERED condition scope (every
dist/define.d.ts:685: * field's scope window) or an EDIT CONDITION — rendered-scope GROQ (`$actor`,
dist/define.d.ts:696: * `bindings` resolves GROQ expressions against the action's rendered scope
dist/define.d.ts:868: * A field declaration whose location determines its scope and lifetime.
dist/define.d.ts:873: * `required: true` is valid only on workflow-scope fields with an `input`
dist/define.d.ts:879: * workflow-scope-only, with at most one subject per definition. Each scope
dist/define.d.ts:914: * `field.inc` and `field.dec` require an initialized `number` field; their
dist/define.d.ts:930: type: "field.set";
dist/define.d.ts:949: type: "field.inc";
dist/define.d.ts:971: * A {@link FieldMutationOp} whose target scope is explicit. Actions can write
dist/define.d.ts:973: * workflow- or stage-scoped field operations, never activity status changes.
dist/define.d.ts:980: scope?: "workflow" | "stage" | undefined;
dist/define.d.ts:1022: * Lake with earlier fields in the same scope available as `$fields`.
dist/define.d.ts:1028: * A `fieldRead` seed reads earlier fields in its own scope when `scope` is
dist/define.d.ts:1030: * `scope: 'workflow'`. An activity seed cannot read stage fields. At workflow
dist/define.d.ts:1031: * scope, omit `scope` to read an earlier workflow field. `path` selects a
dist/define.d.ts:1140: * dialect, not the condition scope (so no {@link ConditionVarBinding}: these
dist/define.d.ts:1391: * are keyed by in-scope field name and apply while the stage is current.
dist/define.d.ts:1465: * A field reference with `scope` already resolved — the form every op target carries.
dist/define.d.ts:1473: readonly scope: v.PicklistSchema<
dist/define.d.ts:1500: * Both use the parent activity's field scope and the acting
[... 1131 more characters trimmed]
Writing the Tattler's story-desk workflow now, checking the engine's field-op shapes first.
$ sed -n '905,1000p' dist/define.d.ts; grep -n "EffectHandler\b\|type EffectHandler\|EffectHandlerResult\|commitOps" dist/index.d.ts | head
of?: FieldShape[] | undefined;
};
/**
* A field mutation targeting a declared field. Resolved values must satisfy
* that field's shape, choices, validation, and assignment constraints.
*
* `field.setIfMissing` supports nullable fields only. If a value exists, it
* leaves the value unchanged and records no `opApplied` history event.
* `field.inc` and `field.dec` require an initialized `number` field; their
* delta defaults to `1`. Both the delta and resulting value must be finite,
* and the result must satisfy the field's validation bounds.
*
* `field.append` adds one valid list member. `field.updateWhere` accepts only
* `array` fields and merges an object into matching rows. The merge cannot
* write `_key` or `_type`, and each resulting row must satisfy its declared
* shape. `field.removeWhere` supports list fields. See {@link Op} for row
* selection and history behavior.
*/
declare type FieldMutationOp<
TTarget extends {
field: string;
},
> =
| {
type: "field.set";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.setIfMissing";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.unset";
target: TTarget;
}
| {
type: "field.append";
target: TTarget;
value: ValueExpr;
}
| {
type: "field.inc";
target: TTarget;
value?: ValueExpr | undefined;
}
| {
type: "field.dec";
target: TTarget;
value?: ValueExpr | undefined;
}
| {
type: "field.updateWhere";
target: TTarget;
where: Condition;
value: ValueExpr;
}
| {
type: "field.removeWhere";
target: TTarget;
where: Condition;
};
/**
* A {@link FieldMutationOp} whose target scope is explicit. Actions can write
* fields in their activity, stage, or workflow. Effect completions accept only
* workflow- or stage-scoped field operations, never activity status changes.
*/
declare type FieldOp = FieldMutationOp<StoredFieldRef>;
/** @inline */
declare type FieldReadExpr = {
type: "fieldRead";
scope?: "workflow" | "stage" | undefined;
field: string;
path?: string | undefined;
};
/**
* A nested field shape inside an object's `fields`, an array's `of`, or an
* effect's `outputs`. It has no initialization, direct editability, or required
* setting; those belong to {@link FieldEntry}.
*
* An `object` requires nonempty `fields` and no `of`. An `array` requires
* nonempty `of` and no `fields`
[... 1198 more characters trimmed]
$ sed -n '3380,3440p' dist/index.d.ts; grep -n "type ValueExpr\b\|declare type ValueExpr" -A25 dist/define.d.ts | head -45
* Delivery is at-least-once: a handler MAY run more than once for the same
* effect. Three causes. A dispatch can die after its side effect but before
* commit. A lease can expire mid-dispatch and be taken over. And every
* admitted attempt of a `retry` policy invokes the handler again. Completion
* is first-writer-wins, and the losing run's completion is reported as `lost`.
*
* Write handlers to tolerate repetition. Derive external identifiers from
* `ctx.effectKey` so the receiving system can dedupe: the key is stable across
* every repeat, including all attempts of one policy run. Checking
* `effectHistory[]` for a row keyed by `ctx.effectKey` before irreversible
* work catches a repeat of an already-completed run, but never a retry
* attempt, because a policy writes one history row when the whole run ends and
* nothing records the attempt that just failed.
*
* The `bivarianceHack` indirection keeps this readable from the non-generic
* `Engine` surface; only the typed drain invokes handlers.
*/
export declare type EffectHandler<
Client extends WorkflowClient = WorkflowClient,
> = {
bivarianceHack(
params: Record<string, unknown>,
ctx: EffectHandlerContext<Client>,
): Promise<{
outputs?: Record<string, unknown>;
ops?: FieldOp[];
} | void>;
}["bivarianceHack"];
export declare type EffectHandlerContext<Client extends WorkflowClient> = {
/** A concrete sibling of the `createEngine` client, bound to the workflow resource with the
* same credentials; untagged handler requests carry the `workflow.effect` tag by default. */
client: Client;
/** Resolve the client for a subject doc's own resource: a handler patching a foreign subject
* routes its write here — {@link client} addresses the instance's. Throws on a bare id. */
clientFor: (ref: GdrUri | GlobalDocumentReference) => WorkflowClient;
instanceId: string;
effectKey: string;
log: (message: string, extra?: Record<string, unknown>) => void;
/** Mid-dispatch `field.*` commit gated on THIS dispatch's exact claim — a superseded claim
* writes nothing. Runs in call order, never coalesced; `idempotencyKey` is required. */
commitOps: (req: CommitOpsRequest) => Promise<void>;
/** Absolute progress, sugar over {@link commitOps}: `field.set`s a number (0–100, enforced at
* commit). Unlike `commitOps`, pending sets to one field coalesce — latest value wins. */
setProgress: (target: ProgressTarget, value: number) => Promise<void>;
};
expor
[... 1192 more characters trimmed]
$ grep -n "declare type FieldSource" -A30 dist/define.d.ts | head -40; grep -n "requirements" dist/define.d.ts | head -5
1034:declare type FieldSource = FieldSourceInternal;
1035-
1036-/** @inline */
1037:declare type FieldSourceInternal =
1038- | {
1039- type: "input";
1040- }
1041- | {
1042- type: "query";
1043- query: string;
1044- }
1045- | LiteralExpr
1046- | FieldReadExpr;
1047-
1048-/** The union a value (or nested {@link FieldShape}) kind may take — see {@link FIELD_VALUE_KINDS}. */
1049-declare type FieldValueKind = (typeof FIELD_VALUE_KINDS)[number];
1050-
1051-/**
1052- * The subset that holds a value in the cascade gates (transition `when`s,
1053- * activity filters, a cascade-fired action's `when`/`filter`).
1054- */
1055-export declare const FILTER_SCOPE_VARS: readonly string[];
1056-
1057-/**
1058- * Hygienic GROQ interpolation for define-time condition building.
1059- *
1060- * Parameterized predicate reuse lives in the host language — a TypeScript
1061- * function producing a condition string — and this tag is its safe splice:
1062- * interpolated *values* serialize as GROQ literals (JSON literal syntax is
1063- * valid GROQ), never as raw query text, so quoting and precedence cannot
1064- * break. The produced string is stored as a plain condition and validated at
1065- * deploy like any other.
1066- *
1067- * ```ts
182: * (`requirements`), and the off-system marker (`target`, a BPMN Manual Task
188: * `requirements` are readiness gates orthogonal to `filter` — an unmet one
190: * `requirements-unmet` verdict; distinct from ACL and guards.
211: requirements?: GroqRequirement[] | undefined;
1079: * requirements array. Activity requirements use instance and caller variables;
D:\Repos\web-apps\estian-tattler\newsroom\workflows\story-desk.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\workflows\story-desk.ts (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\newsroom\sanity.workflow.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\sanity.workflow.ts (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/newsroom && npx sanity-workflows deploy --check 2>&1 | tail -30
[32m✔[39m 1 definition(s) passed validation (check only — dataset not contacted).
$ npx sanity-workflows deploy --dry-run 2>&1 | tail -20 && npx sanity-workflows deploy 2>&1 | tail -20
+ "title": "On the press",
+ "transitions": [
+ {
+ "name": "to-printed",
+ "to": "printed",
+ "when": "$allActivitiesDone"
+ }
+ ]
+ },
+ {
+ "name": "printed",
+ "title": "Printed"
+ },
+ {
+ "name": "spiked",
+ "title": "Spiked"
+ }
+ ],
+ "title": "Story desk"
+ }
- Deploying 1 definition(s) to production (production) → lcvgtfvq.production…
[32m✔[39m Processed 1 definition(s) · production (production) → lcvgtfvq.production
[32m✔[39m created story-desk v1
Definition sharing: ON (default)
New workflow definition versions are being shared with Sanity to improve Workflows.
Examples:
$ sanity-workflows deploy # share by default
$ sanity-workflows deploy --share-defs # share explicitly; hide this warning
$ sanity-workflows deploy --no-share-defs # opt out
$ cd /d/Repos/web-apps/estian-tattler/studio && npx sanity tokens add --help 2>&1 | head -30; cat ../.gitignore 2>/dev/null; ls ..
Create a new API token for the project
USAGE
$ npx sanity tokens add [LABEL] [-p <id>] [--expires-at 2027-01-01]
[--json] [--role viewer] [-y]
ARGUMENTS
[LABEL] Label for the new token
FLAGS
-y, --yes Skip prompts and use defaults (unattended mode)
--expires-at=2027-01-01 Date or timestamp the token expires (ISO 8601;
tokens never expire by default)
--json Output as JSON
--role=viewer Role to assign to the token (defaults to viewer
in unattended mode)
OVERRIDE FLAGS
-p, --project-id=<id> Project ID to create token in (overrides CLI
configuration)
DESCRIPTION
Create a new API token for the project
EXAMPLES
Create a token with a label
$ npx sanity tokens add "My API Token"
Create a token with editor role
data
ingest
newsroom
studio
The workflow is deployed as story-desk v1. Now I'm creating a project token for the newsroom runner and building the effect handlers.
$ cd /d/Repos/web-apps/estian-tattler && printf 'node_modules/\n.env\n*.local\ndist/\n.sanity/\n.next/\nout/\n' > .gitignore && cd studio && npx sanity tokens add "newsroom runner" --role editor --json -y --expires-at 2027-01-31 > /c/tmp/tok.json 2>/c/tmp/tok.err; node -e "const t=require('C:/tmp/tok.json');require('fs').writeFileSync('D:/Repos/web-apps/estian-tattler/newsroom/.env','SANITY_TOKEN='+(t.key||t.token)+'\n');console.log(Object.keys(t))"; rm -f /c/tmp/tok.json; cat /c/tmp/tok.err | head -5
[
'id',
'tokenId',
'label',
'createdAt',
'expiresAt',
'managedBy',
'memberships',
'token'
]
$ cd /d/Repos/web-apps/estian-tattler/newsroom && ls node_modules/@anthropic-ai/claude-agent-sdk/; grep -n "export declare function tool\|export declare function createSdkMcpServer\|outputFormat\|maxTurns\| model?:\|tools?:\|permissionMode?:" node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts | head -20; ls node_modules | head -50; cat package.json
LICENSE.md
README.md
agentSdkTypes.d.ts
bridge.d.ts
bridge.mjs
browser-sdk.d.ts
browser-sdk.js
core-276encc2.mjs
core-5drmq1nt.mjs
core.d.ts
core.mjs
extractFromBunfs.d.ts
extractFromBunfs.js
manifest.json
manifest.zst.json
package.json
sdk-tools.d.ts
sdk.d.ts
sdk.mjs
46: tools?: string[];
58: model?: string;
75: maxTurns?: number;
95: permissionMode?: PermissionMode;
121: model?: string;
328: model?: string;
337: permissionMode?: PermissionMode;
608:export declare function createSdkMcpServer(_options: CreateSdkMcpServerOptions): McpSdkServerConfigWithInstance;
620: tools?: Array<SdkMcpToolDefinition<any>>;
1179: tools?: McpServerToolPolicy[];
1266: tools?: {
1318: tools?: McpServerToolPolicy[];
1637: tools?: string[] | {
1930: maxTurns?: number;
1965: model?: string;
1972: * outputFormat: {
1978: outputFormat?: OutputFormat;
1991: permissionMode?: PermissionMode;
2115: * End-turn tool sessions (`outputFormat: {type: 'json_schema'}`, or any
5026: model?: string | null;
@anthropic-ai
@asamuzakjp
@babel
@bramus
@csstools
@exodus
@hono
@inquirer
@modelcontextprotocol
@oclif
@oxc-project
@rolldown
@sanity
@stablelib
@types
@typescript
accepts
ajv
ajv-formats
ansi-align
ansi-escapes
ansi-regex
ansi-styles
ansis
balanced-match
bidi-js
body-parser
boxen
brace-expansion
bytes
call-bind-apply-helpers
call-bound
camelcase
chalk
chardet
clean-stack
cli-boxes
cli-cursor
cli-spinners
cli-width
color-convert
color-name
content-disposition
content-type
cookie
cookie-signature
cors
cross-spawn
css-tree
csstype
{
"name": "newsroom",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"private": "true",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.282",
"@sanity/client": "^8.7.0",
"@sanity/workflow-cli": "^0.35.0",
"@sanity/workflow-engine": "^0.35.0"
}
}
$ sed -n '590,640p;1960,1995p' node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts; grep -n "export declare function tool" -A12 node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts; grep -n "structured_output" node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts | head -5; ls node_modules/zod node_modules/tsx 2>&1 | head -3
UserPromptExpansionHookInput,
UserPromptExpansionHookSpecificOutput,
UserPromptSubmitHookInput,
UserPromptSubmitHookSpecificOutput,
WorktreeCreateHookInput,
WorktreeCreateHookSpecificOutput,
WorktreeRemoveHookInput
}
}
/**
* Creates an MCP server instance that can be used with the SDK transport.
* This allows SDK users to define custom tools that run in the same process.
*
* Tool calls are bounded by the MCP tool-call timeout — `options.timeout`
* (ms) for this server, else the MCP_TOOL_TIMEOUT env var, effectively
* unbounded by default.
*/
export declare function createSdkMcpServer(_options: CreateSdkMcpServerOptions): McpSdkServerConfigWithInstance;
declare type CreateSdkMcpServerOptions = {
name: string;
version?: string;
/**
* Server instructions returned from `initialize` and surfaced to the model
* as an MCP instructions block. When proxying a real MCP server through the
* SDK transport, pass the underlying server's `getInstructions()` here so
* it isn't dropped.
*/
instructions?: string;
tools?: Array<SdkMcpToolDefinition<any>>;
/**
* When true, all tools from this server are always included in the prompt
* and never deferred behind tool search. Applied via
* `_meta['anthropic/alwaysLoad']` on each tool. Equivalent to
* `defer_loading: false` on the API. Per-tool `tool({ alwaysLoad })` still
* works and is OR'd with this.
*/
alwaysLoad?: boolean;
/**
* Per-server tool-call timeout in milliseconds. Overrides the
* MCP_TOOL_TIMEOUT environment variable for this server. Hard wall-clock
* limit per call; progress notifications do not extend it. Values below
* 1000ms are ignored (falls through to MCP_TOOL_TIMEOUT or the default).
* Applies when the server is first registered; changing it for an
* already-registered server has no effect until it is removed and re-added.
*/
timeout?: number;
};
export declare type CwdChangedHookInput = BaseHookInput & {
mcpServers?: Record<string, McpServerConfig>;
/**
* Claude model to use. Defaults to the CLI default model.
* Examples: 'claude-sonnet-5', 'claude-opus-4-8', 'claude-fable-5'
*/
model?: string;
/**
* Output format configuration for structured responses.
* When specified, the agent will return structured data matching the schema.
*
* @example
* ```typescript
[... 2519 more characters trimmed]
$ grep -n "^ [a-zA-Z]*(.*\|^ [a-zA-Z]*: (" node_modules/@sanity/workflow-engine/dist/index.d.ts | grep -i "instance\|drain\|tick\|pending\|start\|fire\|sweep\|query" | head -30; grep -n "_type.*workflow\|WORKFLOW_INSTANCE_TYPE\|INSTANCE_TYPE" node_modules/@sanity/workflow-engine/dist/index.d.ts | head; sed -n '40,60p' node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts
1274: constructor(args: { instanceId: string; limit: number });
1554: constructor(args: { instanceId: string; attempts: number });
3666: startInstance: (args: StartInstanceArgs) => Promise<OperationResult>;
3670: fireAction: (args: FireActionArgs) => Promise<OperationResult>;
3686: tick: (args: OperationArgs) => Promise<OperationResult>;
3699: abortInstance: (args: AbortInstanceArgs) => Promise<OperationResult>;
3709: getInstance: (args: InstanceRefArgs) => Promise<WorkflowInstance>;
3717: subscriptionDocumentsForInstance: (
3725: session: (args: SessionArgs) => InstanceSession;
3730: guardsForInstance: (args: InstanceRefArgs) => Promise<MutationGuardDoc[]>;
3743: children: (args: ChildrenArgs) => Promise<WorkflowInstance[]>;
3750: instancesForDocument: (
3771: evaluateStart: (args: EvaluateStartArgs) => Promise<StartEvaluation>;
3784: listPendingEffects: (args: InstanceRefArgs) => Promise<PendingEffect[]>;
3786: findPendingEffects: (
3796: drainEffects: (args: DrainEffectsArgs) => Promise<DrainEffectsResult>;
5675: constructor(args: { instanceId: string; detail?: string });
5745: tick(): Promise<OperationResult>;
5747: fireAction(args: {
8075: constructor(args: { instanceId: string; cause: unknown });
8904: startInstance: (
8915: fireAction: (
8981: tick: (
9002: abortInstance: (
9019: getInstance: (
9022: guardsForInstance: (
9053: listPendingEffects: (
9058: findPendingEffects: (
9117: instancesForDocument: (
9157: evaluateStart: (
5425: _type: "subworkflowAdopted";
5434: _type: "subworkflowResolved";
5442: _type: "subworkflowOrphaned";
9174: * stay bare. Mirrors {@link WORKFLOW_INSTANCE_TYPE}.
9184:export declare const WORKFLOW_INSTANCE_TYPE = "sanity.workflow.instance";
10303: _type: typeof WORKFLOW_INSTANCE_TYPE;
10365: * GDR pointing at a {@link WORKFLOW_INSTANCE_TYPE} document in the
10437: _type: typeof WORKFLOW_INSTANCE_TYPE;
* Natural language description of when to use this agent
*/
description: string;
/**
* Array of allowed tool names. If omitted, inherits all tools from parent. Note: passing 'Skill' here is deprecated — use the `skills` field instead.
*/
tools?: string[];
/**
* Array of tool names to explicitly disallow for this agent. MCP server-level specs (mcp__server, mcp__server__*, mcp__*) remove every tool from the named server (or all MCP tools).
*/
disallowedTools?: string[];
/**
* The agent's system prompt
*/
promp
[... 315 more characters trimmed]
$ sed -n '3780,3800p' node_modules/@sanity/workflow-engine/dist/index.d.ts; grep -n "findPendingEffects" -B2 -A15 node_modules/@sanity/workflow-engine/dist/index.d.ts | sed -n '1,40p'; grep -n " tools?: string\[\] | {" -B8 -A6 node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts
* `false`, plus the author's pre-evaluated `$<predicate>` booleans —
* ids in GDR URI form to match the snapshot's keying. */
queryInScope: <T = unknown>(args: QueryInScopeArgs) => Promise<T>;
/** List every pending effect on an instance. */
listPendingEffects: (args: InstanceRefArgs) => Promise<PendingEffect[]>;
/** Filter pending effects by claimed status and/or effect names. */
findPendingEffects: (
args: FindPendingEffectsArgs,
) => Promise<PendingEffect[]>;
/** Dispatch unclaimed effects through registered handlers — including
* entries whose claim's lease expired, which are taken over (audit row)
* and redispatched. The drainer's identity is the engine client's token —
* a drain runtime supplies its own token-bearing client and declares
* itself via `executionContext`. Release-without-dispatch housekeeping is
* the standalone {@link sweepStaleClaims} export, deliberately off the
* engine surface. */
drainEffects: (args: DrainEffectsArgs) => Promise<DrainEffectsResult>;
/**
* Inspect every deployed definition in the engine's tag and apply
* the configured `effects.missingHandler` policy at `phase: "deploy"` for any
* effect name without a registered handler. Catches "definition
3784- listPendingEffects: (args: InstanceRefArgs) => Promise<PendingEffect[]>;
3785- /** Filter pending effects by claimed status and/or effect names. */
3786: findPendingEffects: (
3787- args: FindPendingEffectsArgs,
3788- ) => Promise<PendingEffect[]>;
3789- /** Dispatch unclaimed effects through registered handlers — including
3790- * entries whose claim's lease expired, which are taken over (audit row)
3791- * and redispatched. The drainer's identity is the engine client's token —
3792- * a drain runtime supplies its own token-bearing client and declares
3793- * itself via `executionContext`. Release-without-dispatch housekeeping is
3794- * the standalone {@link sweepStaleClaims} export, deliberately off the
3795- * engine surface. */
3796- drainEffects: (args: DrainEffectsArgs) => Promise<DrainEffectsResult>;
3797- /**
3798- * Inspect every deployed definition in the engine's tag and apply
3799- * the configured `effects.missingHandler` policy at `phase: "deploy"` for any
3800- * effect name without a registered handler. Catches "definition
3801- * shipped, handler removed" misconfigurations at startup instead of
--
9056- /** Filter the instance's pending effects: `claimed
[... 1582 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/workflow-engine && grep -n "pendingEffects\|delta filter\|filter:" DATAMODEL.md | head -15; grep -n "pendingEffects" /c/tmp/wfdocs/sanity-functions.md | head
573:**Effect claim tokens.** `pendingEffects[].claim` grows an optional
58:count(after().pendingEffects[!defined(claim)]) >
59:coalesce(count(before().pendingEffects[!defined(claim)]), 0)
101: 'count(after().pendingEffects[!defined(claim)]) > ' +
102: 'coalesce(count(before().pendingEffects[!defined(claim)]), 0)',
226: `${query}[!defined(completedAt) || count(pendingEffects) > 0]{_id, completedAt}`,
Writing the newsroom's effect handlers and the local desk runner now.
$ sed -n '200,260p' /c/tmp/wfdocs/sanity-functions.md
const dataset = process.env.SANITY_DATASET
if (!projectId || !dataset) {
throw new Error('The Scheduled Function requires SANITY_PROJECT_ID and SANITY_DATASET')
}
const client = createClient({
...context.clientOptions,
projectId,
dataset,
apiVersion: ENGINE_API_VERSION,
perspective: 'raw',
useCdn: false,
})
const executionContext = {kind: 'server', id: 'wf-prod-tick-instances'} as const
const engine = createEngine({
client,
workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
tag: 'prod',
executionContext,
})
const {query, params} = instancesQuery({
tag: 'prod',
filter: {includeCompleted: true},
})
const instances = await client.fetch<Array<{_id: string; completedAt: string | null}>>(
`${query}[!defined(completedAt) || count(pendingEffects) > 0]{_id, completedAt}`,
params,
)
let failed = 0
for (const {_id, completedAt} of instances) {
try {
await sweepStaleClaims({
client,
tag: 'prod',
instanceId: _id,
executionContext,
})
if (completedAt === null) {
await engine.tick({instanceId: _id})
}
} catch (error) {
failed += 1
console.error(`Scheduled recovery failed for ${_id}: ${errorMessage(error)}`)
}
}
if (instances.length > 0 && failed === instances.length) {
throw new Error('Scheduled recovery failed for every selected instance')
}
})
```
`tick()` does not run effect handlers. If this Scheduled Function also owns the handlers, register them on its engine. Call `drainEffects()` for every selected instance after the sweep and optional tick, including completed instances. This runs queued effects in the same invocation, but checks for effects on every schedule.
Handle each instance independently so one failure does not stop recovery for the others. Fail the invocation when every selected instance fails so the problem remains visible in logs.
## Test and deploy
1. Use [Testing functions locally](https://www.sanity.io/docs/functions/functions-local-testing) to exercise both handlers with representative payloads and credentials. Invoke each effect handler more than once with the same `ctx.effectKey` and confirm that it does not repeat the external write.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && cat node_modules/zod/package.json | grep '"version"'; grep -n "AnyZodRawShape =" -A3 node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts | head; grep -n "export declare function instancesQuery\|export declare function extractDocumentId\|export declare const ENGINE_API_VERSION\|export declare function refDataset" node_modules/@sanity/workflow-engine/dist/index.d.ts; npm i -D tsx typescript @types/node 2>&1 | tail -2
"version": "4.6.5",
126:export declare type AnyZodRawShape = ZodRawShape | ZodRawShape_2;
127-
128-/**
129- * Where the credential used for API requests came from: 'ANTHROPIC_API_KEY' (environment variable), 'apiKeyHelper' (the configured helper command), '/login managed key' (an API key created and stored by /login with an Anthropic Console account), or 'none' (no API key in use - e.g. claude.ai OAuth login, a bearer token, or a third-party cloud provider). 'user' | 'project' | 'org' | 'temporary' | 'oauth' are legacy members that current CLIs never emit; they remain only so the type stays backward compatible.
--
5236:export declare type SdkMcpToolDefinition<Schema extends AnyZodRawShape = AnyZodRawShape> = {
5237- name: string;
5238- description: string;
5239- inputSchema: Schema;
3822:export declare const ENGINE_API_VERSION = "2026-04-29";
4226:export declare function extractDocumentId(gdrUriString: string): string;
5809:export declare function instancesQuery(args: {
7041:export declare function refDataset<TType extends string = string>({
found 0 vulnerabilities
D:\Repos\web-apps\estian-tattler\newsroom\lib\desk.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\lib\desk.ts (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\newsroom\effects\fact-check.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\effects\fact-check.ts (file state is current in your context — no need to Read it back)
$ grep -n "settingSources?:\|systemPrompt?:\|persistSession?:\|maxBudgetUsd?:\|effort?:\|thinking?:" -A6 node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts | sed -n '1,80p'; npm i zod 2>&1 | tail -1
91: effort?: ('low' | 'medium' | 'high' | 'xhigh' | 'max') | number;
92- /**
93- * Permission mode controlling how tool executions are handled
94- */
95- permissionMode?: PermissionMode;
96- /**
97- * Agent type auto-spawned as a background observer whenever this agent runs. The observer receives read-only activity digests and reports via the ObserverReport tool; it never participates in the task.
--
191: effort?: {
192- /**
193- * Active effort level for the current turn (e.g., "low", "medium", "high", "xhigh", "max"), after any silent downgrade for the selected model. Also exposed to hook commands and Bash as the CLAUDE_EFFORT env var.
194- */
195- level: string;
196- };
197-};
--
1814: persistSession?: boolean;
1815- /**
1816- * Mirror session transcripts to an external store. When set, the subprocess
1817- * still writes to CLAUDE_CONFIG_DIR (set it to /tmp for ephemeral local copy)
1818- * AND emits entries to this adapter via dual-write.
1819- *
1820- * Cannot be used with persistSession: false -- local writes are required
--
1903: thinking?: ThinkingConfig;
1904- /**
1905- * Controls how much effort Claude puts into its response.
1906- * Works with adaptive thinking to guide thinking depth.
1907- *
1908- * - `'low'` — Minimal thinking, fastest responses
1909- * - `'medium'` — Moderate thinking
--
1916: effort?: EffortLevel;
1917- /**
1918- * Maximum number of tokens the model can use for its thinking/reasoning process.
1919- * Helps control cost and latency for complex tasks.
1920- *
1921- * @deprecated Use `thinking` instead. On Opus 4.6, this is treated as on/off
1922- * (0 = disabled, any other value = adaptive). For explicit control, use
--
1935: maxBudgetUsd?: number;
1936- /**
1937- * API-side task budget in tokens. When set, the model is made aware of
1938- * its remaining token budget so it can pace tool use and wrap up before
1939- * the limit. Sent as `output_config.task_budget` with the
1940- * `task-budgets-2026-03-13` beta header.
1941- * @alpha
--
2249: settingSources?: SettingSource[];
2250- /**
2251- * Skills to enable for the main session. This is the single place to turn
2252- * skills on; you do not need to add `'Skill'` to `allowedTools` yourself
2253- * when using this option.
2254- *
2255- * - omitted (default): no SDK auto-configuration.
[... 1529 more characters trimmed]
D:\Repos\web-apps\estian-tattler\newsroom\effects\draft-story.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\effects\draft-story.ts (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\newsroom\effects\print.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\effects\print.ts (file state is current in your context — no need to Read it back)
The effect handlers are written. Next is the runner that drains the workflow queue, then a live test on a real story.
$ sed -n '5805,5830p' node_modules/@sanity/workflow-engine/dist/index.d.ts; grep -n "declare type ProgressTarget\|type ProgressTarget" -A6 node_modules/@sanity/workflow-engine/dist/index.d.ts; grep -n "interface DrainEffectsResult\|type DrainEffectsResult" -A20 node_modules/@sanity/workflow-engine/dist/index.d.ts | head -30; grep -n "executionContext?:" -B2 -A4 node_modules/@sanity/workflow-engine/dist/index.d.ts | head -20
* {@link InstancesQueryFilter.limit}). Adapters subscribe to it;
* {@link workflow.query}-style one-shot reads fetch it — both see the same
* rows.
*/
export declare function instancesQuery(args: {
tag: string;
filter?: InstancesQueryFilter;
}): CompiledQuery;
/**
* Narrows an instance-list read. All conditions AND together; an empty
* filter means "every in-flight instance in the engine's resource".
*/
export declare interface InstancesQueryFilter {
/**
* Only instances that may reference this document (resource-qualified GDR
* URI). The lake-side predicate matches the reactive watch-set's
* workflow, open-stage, activity, ancestor, live-child, and own-id references.
*/
document?: GdrUri;
/**
* The multi-document form of {@link InstancesQueryFilter.document}: one
* predicate matching instances that reference any of the given docs,
* for consumers discovering instances across many open documents at once.
* Merged with `document` when both are set. Callers may defensively recheck
* with {@link instanceWatchesDocument}. A defined but empty
6909:export declare type ProgressTarget =
6910- | string
6911- | {
6912- scope: "workflow" | "stage";
6913- field: string;
6914- };
6915-
3090:export declare interface DrainEffectsResult {
3091- /** The drained instance's pinned definition fingerprint — carried so the
3092- * drain telemetry event can slice per definition. Absent only for
3093- * instances pinned before content fingerprinting. */
3094- definitionContentHash?: string;
3095- drained: PendingEffect[];
3096- failed: PendingEffect[];
3097- skipped: PendingEffect[];
3098- /**
3099- * Entries this drainer dispatched without settling. Either another party
3100- * settled the entry first (a lease-expiry takeover, a manual recovery, or a
3101- * completion landing while a `retry` run waited out a backoff), or a `retry`
3102- * run stopped because the claim could no longer be held, which leaves the
3103- * entry pending for a later drain. The handler's side effect ran here too:
3104- * the at-least-once overlap the {@link EffectHandler} contract tells
3105- * handlers to tolerate.
3106- */
3107- lost: PendingEffect[];
3108-}
3109-
3110-/**
3896- * Omitted ⇒ runtime-only stamps.
3897- */
3898: executionContext?: DeclaredExecutionContext;
3899- /**
3900- * How long a recorded `idempotencyKey` dedupes retries (the
3901- * `processedRequests[]` row's lifetime). Default
[... 507 more characters trimmed]
D:\Repos\web-apps\estian-tattler\newsroom\desk-runner.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\desk-runner.ts (file state is current in your context — no need to Read it back)
$ node -e "
const p=require('./package.json');p.name='estian-newsroom';p.description='The Estian Tattler newsroom: workflow effect handlers and the desk runner';delete p.main;delete p.keywords;p.author='Booyaka101';p.license='MIT';p.private=true;
p.scripts={desk:'tsx --env-file=.env desk-runner.ts',typecheck:'tsc --noEmit','deploy:workflow':'sanity-workflows deploy'};require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" && cat > tsconfig.json <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": false,
"types": ["node"]
},
"include": ["**/*.ts", "../studio/lib/factcheck.ts"],
"exclude": ["node_modules"]
}
EOF
npx tsc --noEmit 2>&1 | head -30
desk-runner.ts(3,26): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './effects/draft-story.js'?
desk-runner.ts(4,30): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './effects/fact-check.js'?
desk-runner.ts(5,26): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './effects/print.js'?
desk-runner.ts(6,43): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './lib/desk.js'?
effects/draft-story.ts(4,37): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../../studio/lib/factcheck.js'?
effects/draft-story.ts(5,78): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean '../lib/desk.js'?
effects/draft-story.ts(6,24): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './fact-check.js'?
effects/draft-story.ts(78,44): error TS7006: Parameter 'r' implicitly has an 'any' type.
effects/draft-story.ts(96,55): error TS7006: Parameter 'p' implicitly has an 'any' type.
effects/draft-story.ts(96,78): error TS7006: Parameter 'a' implicitly has an 'any' type.
effects/draft-story.ts(97,47): error TS7006: Parameter 'r' implicitly has an 'any' type.
effects/draft-story.ts(101,44): error TS7006: Parameter 'p' implicitly has an 'any' type.
effects/draft-story.ts(101,60): error TS7006: Parameter 'w' implicitly has an 'any' type.
effects/draft-story.ts(112,44): error TS7006: Parameter 'p' implicitly has an 'any' type.
effects/draft-story.ts(112,67): error TS7006: Parameter 'a' implicitly has an 'any' type.
effects/draft-story.ts(116,19): error TS7006: Parameter 'p' implicitly has an 'any' type.
effects/draft-story.ts(117,53): error TS7006: Parameter 'r' implicitly has an 'any' type.
effects/draft-story.ts(117,74): error TS7006: Parameter 'ref' implicitly has an 'any' type.
effects/draft-story.ts(118,40): error TS7006: Parameter 'r' implicitly has an 'any' type.
ef
[... 1468 more characters trimmed]
Switching to bundler resolution, which suits tsx.
$ sed -i 's/"module": "NodeNext"/"module": "ESNext"/; s/"moduleResolution": "NodeNext"/"moduleResolution": "Bundler"/; /allowImportingTsExtensions/d' tsconfig.json && npx tsc --noEmit 2>&1 | head -30
effects/draft-story.ts(61,3): error TS2322: Type '{ _key: string; _type: string; style: string; markDefs: { _key: string; _type: string; records?: object[] | undefined; }[]; children: { _key: string; _type: string; text: string; marks: string[]; }[]; }[]' is not assignable to type 'Block[]'.
Type '{ _key: string; _type: string; style: string; markDefs: { _key: string; _type: string; records?: object[]; }[]; children: { _key: string; _type: string; text: string; marks: string[]; }[]; }' is not assignable to type 'Block'.
Types of property 'markDefs' are incompatible.
Type '{ _key: string; _type: string; records?: object[] | undefined; }[]' is not assignable to type 'MarkDef[]'.
Type '{ _key: string; _type: string; records?: object[] | undefined; }' is not assignable to type 'MarkDef'.
Types of property 'records' are incompatible.
Type 'object[] | undefined' is not assignable to type '{ _ref: string; }[] | undefined'.
Type 'object[]' is not assignable to type '{ _ref: string; }[]'.
Type 'object' is not assignable to type '{ _ref: string; }'.
Property '_ref' is missing in type '{}' but required in type '{ _ref: string; }'.
effects/draft-story.ts(182,32): error TS2345: Argument of type '{ section?: string; pitch?: string; leads?: { _ref: string; }[]; printedAt?: string; edition?: number; _id: string; headline: string; dek: string; body: Block[]; byline: string; factCheck: undefined; }' is not assignable to parameter of type 'IdentifiedSanityDocumentStub<{ section?: string | undefined; pitch?: string | undefined; leads?: { _ref: string; }[] | undefined; printedAt?: string | undefined; edition?: number | undefined; _id: string; headline: string; dek: string; body: Block[]; byline: string; factCheck: undefined; }>'.
Property '_type' is missing in type '{ section?: string; pitch?: string; leads?: { _ref: string; }[]; printedAt?: string; edition?: number; _id: string; headline: string; dek: string; body: Block[]; byline: string; factCheck: undefined; }' but required in type '{ _type: string; }'.
$ python - <<'EOF'
p='effects/draft-story.ts'
s=open(p,encoding='utf-8').read()
s=s.replace("const markDefs: {_key: string; _type: string; records?: object[]}[] = []","const markDefs: {_key: string; _type: string; records?: {_key: string; _type: string; _ref: string}[]}[] = []")
s=s.replace(""" const {_rev, ...rest} = story
await client.createOrReplace({
...rest,
_id: `drafts.${id}`,""",""" const {_rev, factCheck: _stale, ...rest} = story as typeof story & {factCheck?: unknown}
await client.createOrReplace({
...rest,
_id: `drafts.${id}`,
_type: 'story',""")
s=s.replace(""" byline: `${REPORTER_MODEL}, Claude Agent SDK`,
factCheck: undefined,
""",""" byline: `${REPORTER_MODEL}, Claude Agent SDK`,
""")
open(p,'w',encoding='utf-8').write(s)
EOF
npx tsc --noEmit 2>&1 | head
(Bash completed with no output)
Typecheck is clean. Next I'll pitch a real story and run it end to end through the live workflow.
$ cat > /c/tmp/q.mjs <<'EOF'
import {createClient} from '@sanity/client'
const c = createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'})
const r = await c.fetch(process.argv[2])
console.log(JSON.stringify(r,null,1))
EOF
cp /c/tmp/q.mjs ./q.tmp.mjs && PYTHONIOENCODING=utf-8 node --env-file=.env q.tmp.mjs '*[_type=="record" && (def in ["Marriage","Breakup"] || text match "Grasshopper")] | order(tick asc)[0...25]{_id,colonyDay,kind,def,text}' | head -120
[
{
"_id": "record-tale-504",
"colonyDay": 117,
"def": "Wounded",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."
},
{
"_id": "record-tale-505",
"colonyDay": 117,
"def": "Wounded",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."
},
{
"_id": "record-tale-506",
"colonyDay": 117,
"def": "Wounded",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."
},
{
"_id": "record-tale-507",
"colonyDay": 117,
"def": "Wounded",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."
},
{
"_id": "record-tale-508",
"colonyDay": 117,
"def": "Wounded",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."
},
{
"_id": "record-tale-512",
"colonyDay": 119,
"def": "FinishedResearchProject",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga finished researching meat drying."
},
{
"_id": "record-tale-516",
"colonyDay": 120,
"def": "PlayedGame",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga played a game."
},
{
"_id": "record-tale-524",
"colonyDay": 124,
"def": "PlayedGame",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga played a game."
},
{
"_id": "record-tale-525",
"colonyDay": 124,
"def": "WalkedNaked",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga walked around naked."
},
{
"_id": "record-tale-528",
"colonyDay": 125,
"def": "AttendedParty",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga attended a party thrown by Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-tale-529",
"colonyDay": 125,
"def": "AttendedParty",
"kind": "tale",
"text": "Rocaniraalbo Ambmea attended a party thrown by Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-tale-530",
"colonyDay": 125,
"def": "AttendedParty",
"kind": "tale",
"text": "Hyena Embaixada attended a party thrown by Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-tale-531",
"colonyDay": 125,
"def": "AttendedParty",
"kind": "tale",
"text": "Snake Rato attended a party thrown by Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-tale-532",
"colonyDay": 125,
"def": "GainedMasterSkillWithPassion",
"kind": "tale",
"text": "Reiraborvas 'Grasshopper' Canga became a master of intellectual."
},
{
"_id": "record-tale-536",
"colony
[... 431 more characters trimmed]
$ PYTHONIOENCODING=utf-8 node --env-file=.env q.tmp.mjs '*[_type=="record" && (def in ["Marriage","Breakup","SocialFight"] || (kind!="tale" && (text match "Snake" || label match "Snake")))] | order(tick asc){_id,colonyDay,kind,def,label,text}' | head -150
[
{
"_id": "record-tale-547",
"colonyDay": 131,
"def": "SocialFight",
"kind": "tale",
"label": null,
"text": "Hyena Embaixada got into a fist fight with Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-tale-554",
"colonyDay": 132,
"def": "Marriage",
"kind": "tale",
"label": null,
"text": "Snake Rato married Reiraborvas 'Grasshopper' Rato."
},
{
"_id": "record-tale-1036",
"colonyDay": 252,
"def": "Breakup",
"kind": "tale",
"label": null,
"text": "Snake Rato broke up with Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-letter-581",
"colonyDay": 265,
"def": "NegativeEvent",
"kind": "letter",
"label": "Food binge: Grasshopper",
"text": "Grasshopper is pigging out on food.\n\nThis happened because of poor mood.\n\nThe final straw was: Divorced by Snake"
},
{
"_id": "record-message-1397",
"colonyDay": 265,
"def": "ThreatSmall",
"kind": "message",
"label": null,
"text": "Snake started a social fight with Rocaniraalbo."
},
{
"_id": "record-message-1400",
"colonyDay": 265,
"def": "SituationResolved",
"kind": "message",
"label": null,
"text": "Snake and Rocaniraalbo are no longer social fighting."
},
{
"_id": "record-tale-1070",
"colonyDay": 265,
"def": "SocialFight",
"kind": "tale",
"label": null,
"text": "Snake Rato got into a fist fight with Rocaniraalbo Ambmea."
},
{
"_id": "record-message-1416",
"colonyDay": 268,
"def": "PositiveEvent",
"kind": "message",
"label": null,
"text": "Snake, Warrior is fully healed."
},
{
"_id": "record-letter-586",
"colonyDay": 268,
"def": "PositiveEvent",
"kind": "letter",
"label": "Inspired recruitment: Snake",
"text": "Thanks to high mood, Snake has experienced an inspiration!\n\nHe will successfully recruit the next prisoner regardless of difficulty or resistance, as long as they are not unwaveringly loyal.\n\nThe inspiration will end after 8 days, or when the prisoner is recruited."
},
{
"_id": "record-letter-595",
"colonyDay": 271,
"def": "NegativeEvent",
"kind": "letter",
"label": "Disease: Muscle parasites",
"text": "2 of your natives have gotten sick from muscle parasites.\n\nEnsure you have a medical bed and a doctor. Make sure that they get proper treatment and spend as much time in bed as possible.\n\nThe following natives have gotten sick:\n\n - Snake, Warrior\n - Embaixada, Thane"
},
{
"_id": "record-message-1452",
"colonyDay": 276,
"def": "NeutralEvent",
"kin
[... 1442 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio && python - <<'EOF'
p='lib/factcheck.ts'
s=open(p,encoding='utf-8').read()
old_names=s[s.index('function namesIn'):s.index('function checkClaim')]
new_names='''/** Names the text mentions, each with every pawn who answers to it. Married couples share a surname. */
function namesIn(text: string, pawns: PawnInput[]): Map<string, string[]> {
const named = new Map<string, string[]>()
for (const pawn of pawns) {
for (const alias of pawn.aliases ?? []) {
if (alias.length < 3) continue
const escaped = alias.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')
if (new RegExp(`(?<![\\\\p{L}])${escaped}(?![\\\\p{L}])`, 'u').test(text)) named.set(alias, [...(named.get(alias) ?? []), pawn._id])
}
}
return named
}
'''
s=s.replace(old_names,new_names)
s=s.replace(''' for (const [pawnId, alias] of namesIn(text, pawns)) {
if (!inReceipts.has(pawnId)) problems.push''',''' for (const [alias, pawnIds] of namesIn(text, pawns)) {
if (!pawnIds.some((id) => inReceipts.has(id))) problems.push''')
s=s.replace("for (const alias of namesIn(text, pawns).values()) problems.push","for (const alias of namesIn(text, pawns).keys()) problems.push")
open(p,'w',encoding='utf-8').write(s)
EOF
sed -n '26,50p' lib/factcheck.ts
/** Names the text mentions, each with every pawn who answers to it. Married couples share a surname. */
function namesIn(text: string, pawns: PawnInput[]): Map<string, string[]> {
const named = new Map<string, string[]>()
for (const pawn of pawns) {
for (const alias of pawn.aliases ?? []) {
if (alias.length < 3) continue
const escaped = alias.replace(/[.*+?^${}()|[\]\]/g, '\$&')
if (new RegExp(`(?<![\p{L}])${escaped}(?![\p{L}])`, 'u').test(text)) named.set(alias, [...(named.get(alias) ?? []), pawn._id])
}
}
return named
}
function checkClaim(text: string, cited: RecordInput[], pawns: PawnInput[], problems: Problem[]) {
const inReceipts = new Set(cited.flatMap((r) => (r.pawns ?? []).map((p) => p._ref)))
for (const [alias, pawnIds] of namesIn(text, pawns)) {
if (!pawnIds.some((id) => inReceipts.has(id))) problems.push({text, reason: `names ${alias}, but no receipt mentions them`})
}
const allowed = new Set<number>([cited.length])
for (const r of cited) {
if (r.colonyDay !== undefined) allowed.add(r.colonyDay)
for (const n of numbersIn(`${r.label ?? ''} ${r.text ?? ''}`)) allowed.add(n)
}
for (const n of numbersIn(text)) {
if (!allowed.has(n)) problems.push({text, reason: `says ${n}, which isn't in its receipts`})
The heredoc swallowed the backslashes again. Fixing those two lines with Edit.
D:\Repos\web-apps\estian-tattler\studio\lib\factcheck.ts
The file D:\Repos\web-apps\estian-tattler\studio\lib\factcheck.ts has been updated successfully. (note: the file had been modified on disk since you last read it — the edit applied cleanly, but the file contains other changes not in your context. Read it before edits that depend on surrounding content.)
Now a small test suite for the fact-checker. It's the core of the idea, so it should be pinned down.
D:\Repos\web-apps\estian-tattler\newsroom\test\factcheck.test.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\newsroom\test\factcheck.test.ts (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/newsroom && node -e "const p=require('./package.json');p.scripts.test='tsx --test test/*.test.ts';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" && SANITY_TOKEN=x npm test 2>&1 | tail -30
duration_ms: 0.119
type: 'test'
...
# Subtest: a sentence with no receipt and no aside mark fails
ok 6 - a sentence with no receipt and no aside mark fails
---
duration_ms: 0.1554
type: 'test'
...
# Subtest: the headline can only use what the body proved
ok 7 - the headline can only use what the body proved
---
duration_ms: 0.1182
type: 'test'
...
# Subtest: citing a record that does not exist fails
ok 8 - citing a record that does not exist fails
---
duration_ms: 0.0806
type: 'test'
...
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 874.1169
All 8 pass. Now the live end-to-end run: I'll pitch the Snake and Grasshopper divorce story and start a workflow instance on it.
$ cat > pitch.tmp.mjs <<'EOF'
import {createClient} from '@sanity/client'
const c = createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false})
const leads = ['record-tale-554','record-tale-1036','record-letter-581','record-letter-604','record-talk-14672']
await c.createIfNotExists({_id:'story-snake-grasshopper', _type:'story', section:'Hearts', headline:'Snake and Grasshopper: the marriage, the split, the binges',
pitch:'They married on Day 132 and split on Day 252. Grasshopper binged twice after the divorce. Are they talking again?',
leads: leads.map(id=>({_key:id.replace(/\W/g,''),_type:'reference',_ref:id}))})
console.log('ok')
EOF
node --env-file=.env pitch.tmp.mjs && npx sanity-workflows start --help 2>&1 | head -30
ok
Start a workflow instance from a deployed definition. Supply values for the workflow's input-sourced fields with --field (e.g. the subject document ref).
USAGE
$ sanity-workflows start NAME [--deployment <value> | --tag
<value>] [--version <value>] [--field <value>...] [--instance-id <value>]
[--json]
ARGUMENTS
NAME Workflow definition name.
FLAGS
--deployment=<value> Deployment name — the unique identity of one deployment
in the config.
--field=<value>... [default: ] Initial value for a declared input-sourced
field, as name=value (repeatable). Values are
JSON-parsed, falling back to a string; ref kinds take a
JSON object with a GDR `id` and doc `type`.
--instance-id=<value> Start under this instance id — for retries. The id is
the start's idempotency key: pass the id of a start
that failed partway and the engine resumes it instead
of creating a duplicate (an already-settled start
replays as a no-op). Omit to mint a fresh id.
--json Emit structured JSON instead of rendered output.
--tag=<value> Workflow environment tag (e.g. prod, test) — selects
the deployment to act on while the tag names exactly
one; pass --deployment when it spans several.
--version=<value> Definition version to start from (default: highest
deployed).
DESCRIPTION
$ npx sanity-workflows start story-desk --field 'subject={"id":"dataset:lcvgtfvq:production:story-snake-grasshopper","type":"story"}' 2>&1 | tail -20
- Starting story-desk…
[32m✔[39m Started production.wf-instance.a46f134ebcc0 — now at reporting
Instance started at reporting. Now running the newsroom once, in the foreground. The reporter is Claude working through the records.
$ timeout 590 npm run desk -- --once 2>&1 | tail -30
> estian-newsroom@1.0.0 desk
> tsx --env-file=.env desk-runner.ts --once
[effect.draft-story] filed "Snake and Grasshopper: the marriage, the split, the binges" after 7 tool calls
[effect.fact-check] passed: 8 claims, 0 problems
production.wf-instance.a46f134ebcc0 draft-story done (74s)
production.wf-instance.a46f134ebcc0 fact-check done (74s)
It ran first time: drafted in 74s, fact-check passed. Let me read what the reporter actually wrote and confirm the instance is sitting at the editor's desk.
$ PYTHONIOENCODING=utf-8 node --env-file=.env q.tmp.mjs '*[_id=="drafts.story-snake-grasshopper"][0]{headline,dek,byline,factCheck{passed,claims,asides},"paras":body[]{"s":children[]{text,"m":^.markDefs[_key in ^.marks][0]{_type,"r":records[]._ref}}}}' ; npx sanity-workflows show production.wf-instance.a46f134ebcc0 2>&1 | head -30
{
"byline": "claude-opus-5-5, Claude Agent SDK",
"dek": "Wed on Day 132, split on Day 252, and chatting again on Day 305",
"factCheck": {
"asides": 8,
"claims": 8,
"passed": true
},
"headline": "Snake and Grasshopper: the marriage, the split, the binges",
"paras": [
{
"s": [
{
"m": {
"_type": "aside",
"r": null
},
"text": "Every great romance starts somewhere, and this pairing seems to have started at a party. "
},
{
"m": {
"_type": "claim",
"r": [
"record-tale-531"
]
},
"text": "On Day 125, Snake Rato attended a party thrown by Reiraborvas 'Grasshopper' Canga."
}
]
},
{
"s": [
{
"m": {
"_type": "claim",
"r": [
"record-tale-554"
]
},
"text": "Just a week later, on Day 132, Snake married Grasshopper, who took his name and became Grasshopper Rato. "
},
{
"m": {
"_type": "claim",
"r": [
"record-tale-679",
"record-tale-863"
]
},
"text": "The newlyweds kept the social calendar full: Snake went to a party Grasshopper threw on Day 150, and she returned the favour at his party on Day 220."
}
]
},
{
"s": [
{
"m": {
"_type": "aside",
"r": null
},
"text": "Then the music stopped. "
},
{
"m": {
"_type": "claim",
"r": [
"record-tale-1036"
]
},
"text": "On Day 252, Snake broke up with Grasshopper, and the records have her back under the name Canga. "
},
{
"m": {
"_type": "aside",
"r": null
},
"text": "What went wrong? The files don't say, and this paper won't guess."
}
]
},
{
"s": [
{
"m": {
"_type": "aside",
"r": null
},
"text": "The fallout was not pretty. "
},
{
"m": {
"_type": "claim",
"r": [
"record-letter-581"
]
},
"text": "On Day 265 Grasshopper was caught pigging out on food because of poor mood, and the final straw, per the colony's own report, was being divorced by Snake. "
},
{
"m": {
"_type": "claim",
"r": [
"record-letter-604"
]
},
"text": "On Day 277 it happened again, with the very same final straw: divorced by Snake."
}
]
},
{
"s": [
{
"m": {
"_type": "aside",
"r": null
},
"text": "The other side of the split wasn't exactly tranquil either.
[... 1333 more characters trimmed]
The reporter's output is strong and every claim carries receipts. Two things the mechanical checker can't verify are pronouns and "a few hours later", so I'll spot-check those myself.
$ PYTHONIOENCODING=utf-8 node --env-file=.env q.tmp.mjs '{"p":*[_type=="pawn" && shortName in ["Snake","Grasshopper"]]{shortName,gender},"t":*[_id in ["record-talk-14672","record-talk-14676","record-tale-1070","record-letter-581","record-tale-679","record-tale-863"]]{_id,colonyDay,hour,text}}'
{
"p": [
{
"gender": "Male",
"shortName": "Snake"
},
{
"gender": "Female",
"shortName": "Grasshopper"
}
],
"t": [
{
"_id": "record-letter-581",
"colonyDay": 265,
"hour": 1,
"text": "Grasshopper is pigging out on food.\n\nThis happened because of poor mood.\n\nThe final straw was: Divorced by Snake"
},
{
"_id": "record-tale-1070",
"colonyDay": 265,
"hour": 23,
"text": "Snake Rato got into a fist fight with Rocaniraalbo Ambmea."
},
{
"_id": "record-tale-679",
"colonyDay": 150,
"hour": 21,
"text": "Snake Rato attended a party thrown by Reiraborvas 'Grasshopper' Rato."
},
{
"_id": "record-tale-863",
"colonyDay": 220,
"hour": 6,
"text": "Reiraborvas 'Grasshopper' Rato attended a party thrown by Snake Rato."
},
{
"_id": "record-talk-14672",
"colonyDay": 305,
"hour": 9,
"text": "Snake Rato chatted with Reiraborvas 'Grasshopper' Canga."
},
{
"_id": "record-talk-14676",
"colonyDay": 305,
"hour": 13,
"text": "Reiraborvas 'Grasshopper' Canga chatted with Snake Rato."
}
]
}
Everything checks out against the records: she/he, hour 1 binge, hour 23 fight, 9h then 13h chats. Now testing the editor's approve → print path.
$ npx sanity-workflows fire-action --help 2>&1 | sed -n '1,25p'
Fire an action on an instance to unstick a waiting activity — the write acts as the configured token. Omit --action to list what can be fired.
USAGE
$ sanity-workflows fire-action INSTANCEID [--deployment <value> | --tag
<value>] [--activity <value>] [--action <value>] [--param <value>...]
[--json]
ARGUMENTS
INSTANCEID Workflow instance id.
FLAGS
--action=<value> Action to fire. Omit to list the actions available on
the instance.
--activity=<value> Activity the action belongs to. Required to fire; omit
--action to list.
--deployment=<value> Deployment name — narrow the instance search to the
resource that deployment targets; the tag partition
still comes from the loaded instance.
--json Emit structured JSON instead of rendered output.
--param=<value>... [default: ] Action param as key=value (repeatable).
Values are JSON-parsed, falling back to a string.
--tag=<value> Workflow environment tag (e.g. prod, test) — an optional
query filter that also narrows which resources are
searched; omit to span them all.
$ sed -n '1,200p' /c/tmp/wfdocs/studio-plugin.md
> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).
# Add Workflows to Sanity Studio
Install the Workflows plugin in a Sanity Studio, bind it to your deployed definitions, and put workflows in front of editors.
> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) before you rely on it.
The Workflows Studio plugin connects deployed workflow definitions to Sanity Studio. It adds a workflow strip and a Workflows view to the document editor, and a Workflows tool for tracking work across documents.
This guide starts from a Studio with no workflow UI and ends with an editor starting a workflow on a document. For what editors see and do once it is running, read [Workflows in Sanity Studio](https://www.sanity.io/docs/workflows/studio-user-guide).
Before you start, you need:
- At least one deployed workflow definition, and the tag it was deployed under: see [Configure and deploy workflow definitions](https://www.sanity.io/docs/workflows/deploy-definitions).
- Sanity Studio v6.15.0 or later (v6.x).
- React and React DOM 19.2.7 or later.
- `styled-components` 6.4.2 or later.
- `@sanity/sdk` 3.1 or later in the 3.x line. The plugin requires this dependency directly.
- Node.js 20 or later.
> [!NOTE]
> Only the plugin needs a Studio
> Studio 6 is a major version, so a Studio still on v5 needs that upgrade before the plugin installs, and that upgrade is its own piece of work. The engine, the CLI, the App SDK adapter, and the MCP server have no dependency on Sanity Studio, so you can author, deploy, and drive workflows while the Studio upgrade is still ahead of you. The [quick start](https://www.sanity.io/docs/workflows/getting-started) runs end to end without one.
## Install the packages
Install the packages in a Sanity Studio 6.15 or later project in the 6.x line. The plugin reads the definitions you deployed with the Workflows CLI.
Install every `@sanity/workflow-*` package at the same version. The command includes the plugin’s required Workflows packages. For an existing installation, follow [the compatibility and upgrade instructions](https://www.sanity.io/docs/workflows/upgrade).
SDK 3.1.0 can resolve `@sanity/mutate` 0.18.1, which can leave document reads pending with Sanity client 8. Before installing, merge this override into your application’s root
[... 8949 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio && cat package.json && cat sanity.config.ts sanity.cli.ts 2>/dev/null; ls
{
"name": "estian-tattler",
"private": true,
"version": "1.0.0",
"main": "package.json",
"license": "UNLICENSED",
"scripts": {
"build": "sanity build",
"deploy": "sanity deploy",
"deploy-graphql": "sanity graphql deploy",
"dev": "sanity dev",
"start": "sanity start"
},
"keywords": [
"sanity"
],
"dependencies": {
"@sanity/ui": "^4.2.4",
"@sanity/vision": "^6.16.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sanity": "^6.16.0",
"styled-components": "^6.1.18"
},
"devDependencies": {
"@sanity/eslint-config-studio": "^7",
"@types/react": "^19.2.14",
"eslint": "^10.8.1",
"prettier": "^3.5",
"typescript": "^5.8"
},
"prettier": {
"bracketSpacing": false,
"printWidth": 100,
"semi": false,
"singleQuote": true
}
}
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {visionTool} from '@sanity/vision'
import {schemaTypes} from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'Estian Tattler',
projectId: 'lcvgtfvq',
dataset: 'production',
plugins: [structureTool(), visionTool()],
schema: {
types: schemaTypes,
},
})
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: 'lcvgtfvq',
dataset: 'production'
},
deployment: {
/**
* Enable auto-updates for studios.
* Learn more at https://www.sanity.io/docs/studio/latest-version-of-sanity#k47faf43faf56
*/
autoUpdates: true,
},
})
README.md
components
eslint.config.mjs
lib
node_modules
package-lock.json
package.json
sanity.cli.ts
sanity.config.ts
schemaTypes
static
tsconfig.json
tsconfig.tsbuildinfo
$ node -e "const p=require('./package.json');p.overrides={'@sanity/sdk':{'@sanity/mutate':'0.18.2'}};require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" && npm install @sanity/sdk@^3.1 @sanity/workflow-studio-plugin@0.35.0 @sanity/workflow-components@0.35.0 @sanity/workflow-diagram@0.35.0 @sanity/workflow-engine@0.35.0 @sanity/workflow-react@0.35.0 @sanity/workflow-sdk@0.35.0 @sanity/workflow-studio@0.35.0 react@^19.2.7 react-dom@^19.2.7 styled-components@^6.4.2 2>&1 | tail -5; npm ls @sanity/mutate 2>&1 | head -12
Some issues need review, and may require choosing
a different dependency.
Run `npm audit` for details.
estian-tattler@1.0.0 D:\Repos\web-apps\estian-tattler\studio
+-- @sanity/sdk@3.5.0 overridden
| `-- @sanity/mutate@0.18.2
`-- sanity@6.16.0
+-- @sanity/migrate@8.0.5
| `-- @sanity/mutate@0.18.2 deduped
`-- @sanity/mutate@0.18.2 deduped
$ cat schemaTypes/story.ts; cat schemaTypes/record.ts | head -60
import {defineArrayMember, defineField, defineType} from 'sanity'
import {ClaimAnnotation, AsideAnnotation} from '../components/annotations'
export const SECTIONS = ['Front page', 'Hearts', 'Science', 'Brawls', 'Commerce', 'Weather', 'Graves']
export const story = defineType({
name: 'story',
title: 'Story',
type: 'document',
fields: [
defineField({name: 'headline', type: 'string', validation: (rule) => rule.required().max(90)}),
defineField({name: 'dek', type: 'string', description: 'The line under the headline.'}),
defineField({name: 'section', type: 'string', options: {list: SECTIONS}}),
defineField({
name: 'pitch',
type: 'text',
rows: 2,
description: 'What the desk wants from the reporter.',
}),
defineField({
name: 'leads',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'record'}]})],
description: 'The records the pitch starts from. The reporter can dig up more.',
}),
defineField({
name: 'body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
styles: [{title: 'Normal', value: 'normal'}],
lists: [],
marks: {
decorators: [],
annotations: [
{
name: 'claim',
type: 'object',
title: 'Claim',
components: {annotation: ClaimAnnotation},
fields: [
defineField({
name: 'records',
type: 'array',
of: [defineArrayMember({type: 'reference', to: [{type: 'record'}]})],
validation: (rule) => rule.min(1),
}),
],
},
{
name: 'aside',
type: 'object',
title: 'Aside',
description: "The paper's opinion. Can't name a pawn or carry a number.",
components: {annotation: AsideAnnotation},
fields: [defineField({name: 'note', type: 'string'})],
},
],
},
}),
],
}),
defineField({name: 'byline', type: 'string'}),
defineField({
name: 'factCheck',
type: 'object',
readOnly: true,
description: 'Written by the fact-check desk. Every sentence needs a receipt or an aside mark.',
fields: [
defineField({name: 'passed', type: 'boolean'}),
def
[... 2733 more characters trimmed]
D:\Repos\web-apps\estian-tattler\studio\lib\morgue.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\lib\morgue.ts (file state is current in your context — no need to Read it back)
$ python - <<'EOF'
p='schemaTypes/story.ts'
s=open(p,encoding='utf-8').read()
s=s.replace("import {ClaimAnnotation, AsideAnnotation} from '../components/annotations'\n","import {ClaimAnnotation, AsideAnnotation} from '../components/annotations'\nimport {checkWithMorgue} from '../lib/morgue'\nimport type {StoryInput} from '../lib/factcheck'\n")
s=s.replace(""" type: 'document',
fields: [""",""" type: 'document',
// The desk won't publish a story the fact-checker would reject, whoever edited it last.
validation: (rule) =>
rule.custom(async (doc, context) => {
if (!doc) return true
const {verdict} = await checkWithMorgue(context.getClient({apiVersion: '2025-02-19'}), doc as StoryInput)
if (verdict.passed) return true
return verdict.problems.slice(0, 3).map((p) => `"${p.text}" ${p.reason}`).join('\\n')
}),
fields: [""",1)
open(p,'w',encoding='utf-8').write(s)
EOF
grep -n "join('" schemaTypes/story.ts
18: return verdict.problems.slice(0, 3).map((p) => `"${p.text}" ${p.reason}`).join('
112: .join(' · '),
The Studio is getting live fact-check validation, so a failing story can't be published. Fixing a heredoc newline slip first.
D:\Repos\web-apps\estian-tattler\studio\schemaTypes\story.ts
14 rule.custom(async (doc, context) => {
15 if (!doc) return true
16 const {verdict} = await checkWithMorgue(context.getClient({apiVersion: '2025-02-19'}), doc as StoryInput)
17 if (verdict.passed) return true
18 return verdict.problems.slice(0, 3).map((p) => `"${p.text}" ${p.reason}`).join('
19 ')
20 }),
D:\Repos\web-apps\estian-tattler\studio\schemaTypes\story.ts
The file D:\Repos\web-apps\estian-tattler\studio\schemaTypes\story.ts has been updated successfully. (file state is current in your context — no need to Read it back)
Now the Receipts view: every sentence of the story next to the records it stands on, plus the live verdict.
D:\Repos\web-apps\estian-tattler\studio\components\ReceiptsView.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\components\ReceiptsView.tsx (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\studio\structure.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\structure.ts (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\studio\components\PitchAction.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\studio\components\PitchAction.tsx (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\studio\sanity.config.ts
The file D:\Repos\web-apps\estian-tattler\studio\sanity.config.ts has been updated successfully. (file state is current in your context — no need to Read it back)
$ ls node_modules/@sanity/icons >/dev/null 2>&1 && echo icons-ok; npx tsc --noEmit 2>&1 | head -30
icons-ok
Typecheck is clean. Building the Studio, then deploying the schema and the Studio.
$ npm i @sanity/icons 2>&1 | tail -1; sed -i "s/ api: {/ studioHost: 'estian-tattler',\n api: {/" sanity.cli.ts && timeout 500 npx sanity build 2>&1 | tail -15
Run `npm audit` for details.
- Clean output folder
[32m✔[39m Clean output folder (1ms)
- Build Sanity Studio
[31m✖[39m Build Sanity Studio
» Error: Failed to build Sanity Studio: [31mBuild failed with 1 error:
»
» [31m[MISSING_EXPORT] [0m"DocumentTextIcon" is not exported by
» "node_modules/@sanity/icons/dist/index.js".
» [38;5;246m╭[0m[38;5;246m─[0m[38;5;246m[[0m components/PitchAction.tsx:2:10 [38;5;246m][0m
» [38;5;246m│[0m
» [38;5;246m2 │[0m [38;5;249mi[0m[38;5;249mm[0m[38;5;249mp[0m[38;5;249mo[0m[38;5;249mr[0m[38;5;249mt[0m[38;5;249m [0m[38;5;249m{[0m[38;5;249m [0mDocumentTextIcon[38;5;249m [0m[38;5;249m}[0m[38;5;249m [0m[38;5;249mf[0m[38;5;249mr[0m[38;5;249mo[0m[38;5;249mm[0m[38;5;249m [0m[38;5;249m"[0m[38;5;249m@[0m[38;5;249ms[0m[38;5;249ma[0m[38;5;249mn[0m[38;5;249mi[0m[38;5;249mt[0m[38;5;249my[0m[38;5;249m/[0m[38;5;249mi[0m[38;5;249mc[0m[38;5;249mo[0m[38;5;249mn[0m[38;5;249ms[0m[38;5;249m"[0m[38;5;249m;[0m
» [38;5;240m │[0m ────────┬───────
» [38;5;240m │[0m ╰───────── Missing export
» [38;5;246m───╯[0m
» [39m
$ grep -o "export {[^}]*}" node_modules/@sanity/icons/dist/index.js | tr ',' '\n' | grep -i "document\|compose\|edit\|bulb" | head; cat tsconfig.json | head -30
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "Preserve",
"moduleDetection": "force",
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}
$ grep -o "[A-Za-z]*Icon\b" node_modules/@sanity/icons/dist/index.d.ts | sort -u | grep -i "document\|compose\|edit\|bulb" | head; grep '"version"' node_modules/@sanity/icons/package.json
AddDocumentIcon
BinaryDocumentIcon
BulbFilledIcon
BulbOutlineIcon
ComposeIcon
ComposeSparklesIcon
CreditCardIcon
DocumentIcon
DocumentPdfIcon
DocumentRemoveIcon
"version": "5.2.2",
$ sed -i 's/DocumentTextIcon/ComposeIcon/g' components/PitchAction.tsx && timeout 500 npx sanity build 2>&1 | tail -6
» [38;5;246m│[0m
» [38;5;246m2 │[0m [38;5;249mi[0m[38;5;249mm[0m[38;5;249mp[0m[38;5;249mo[0m[38;5;249mr[0m[38;5;249mt[0m[38;5;249m [0m[38;5;249m{[0m[38;5;249m [0mComposeIcon[38;5;249m [0m[38;5;249m}[0m[38;5;249m [0m[38;5;249mf[0m[38;5;249mr[0m[38;5;249mo[0m[38;5;249mm[0m[38;5;249m [0m[38;5;249m"[0m[38;5;249m@[0m[38;5;249ms[0m[38;5;249ma[0m[38;5;249mn[0m[38;5;249mi[0m[38;5;249mt[0m[38;5;249my[0m[38;5;249m/[0m[38;5;249mi[0m[38;5;249mc[0m[38;5;249mo[0m[38;5;249mn[0m[38;5;249ms[0m[38;5;249m"[0m[38;5;249m;[0m
» [38;5;240m │[0m ─────┬─────
» [38;5;240m │[0m ╰─────── Missing export
» [38;5;246m───╯[0m
» [39m
$ ls node_modules/@sanity/icons/dist | head; grep -c "ComposeIcon" node_modules/@sanity/icons/dist/index.js; tail -c 600 node_modules/@sanity/icons/dist/index.js; npm ls @sanity/icons 2>&1 | head
AccessDenied.d.ts
AccessDenied.d.ts.map
AccessDenied.js
AccessDenied.js.map
Activity.d.ts
Activity.d.ts.map
Activity.js
Activity.js.map
Add.d.ts
Add.d.ts.map
0
ch: lazy(() => import("./Wrench.js"))
}, Icon = /* @__PURE__ */ forwardRef(function Icon(props, ref) {
let { symbol, ...restProps } = props, IconComponent = icons[symbol];
return IconComponent ? /* @__PURE__ */ jsx(Suspense, {
fallback: /* @__PURE__ */ jsx("svg", {
"data-sanity-icon": symbol,
width: "1em",
height: "1em",
viewBox: "0 0 25 25",
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
...restProps,
ref
}),
children: /* @__PURE__ */ jsx(IconComponent, {
...restProps,
ref
})
}) : null;
});
export { Icon, icons };
//# sourceMappingURL=index.js.mapestian-tattler@1.0.0 D:\Repos\web-apps\estian-tattler\studio
+-- @sanity/icons@5.2.2
+-- @sanity/ui@4.2.4
| `-- @sanity/icons@5.2.2 deduped
+-- @sanity/vision@6.16.0
| +-- @sanity/icons@5.2.2 deduped
| `-- ui5@npm:@sanity/ui@5.0.0-alpha.10
| `-- @sanity/icons@5.2.2 deduped
+-- @sanity/workflow-components@0.35.0
| `-- @sanity/icons@4.1.0
Icons v5 ships one module per icon. Switching to the per-file import.
$ ls node_modules/@sanity/icons/dist | grep -i "^compose"; grep -n "exports" -A8 node_modules/@sanity/icons/package.json | head -14
Compose.d.ts
Compose.d.ts.map
Compose.js
Compose.js.map
ComposeSparkles.d.ts
ComposeSparkles.d.ts.map
ComposeSparkles.js
ComposeSparkles.js.map
27: "exports": {
28- ".": "./dist/index.js",
29- "./AccessDenied": "./dist/AccessDenied.js",
30- "./Activity": "./dist/Activity.js",
31- "./Add": "./dist/Add.js",
32- "./AddCircle": "./dist/AddCircle.js",
33- "./AddComment": "./dist/AddComment.js",
34- "./AddDocument": "./dist/AddDocument.js",
35- "./AddUser": "./dist/AddUser.js",
--
303: "generate": "rimraf src/icons src/icons.ts src/exports src/deprecations.ts && unrun scripts/generate.ts",
304- "prebuild": "pnpm generate",
305- "seed:icons": "unrun scripts/seed-icons-dataset.ts",
306- "test": "vitest run",
$ grep -o "export {[^}]*}" node_modules/@sanity/icons/dist/Compose.js; sed -i "s#import {ComposeIcon} from '@sanity/icons'#import {ComposeIcon} from '@sanity/icons/Compose'#" components/PitchAction.tsx && npx tsc --noEmit 2>&1 | head -5 && timeout 500 npx sanity build 2>&1 | tail -4
export { ComposeIcon, ComposeIcon as default }
- Clean output folder
[32m✔[39m Clean output folder (4ms)
- Build Sanity Studio
[32m✔[39m Build Sanity Studio (1932ms)
Build passes. Deploying the Studio and the schema.
$ timeout 500 npx sanity deploy -y 2>&1 | tail -12
Add appId: 'uai6f1wkpsf49r3et3gtpiio'
to the `deployment` section in sanity.cli.js or sanity.cli.ts
to avoid prompting for application id on next deploy.
Example:
export default defineCliConfig({
//…
deployment: {
appId: 'uai6f1wkpsf49r3et3gtpiio',
},
//…
})
$ sed -i "s/ deployment: {/ deployment: {\n appId: 'uai6f1wkpsf49r3et3gtpiio',/" sanity.cli.ts && cat sanity.cli.ts && timeout 300 npx sanity schema deploy 2>&1 | tail -5; curl -s -o /dev/null -w "%{http_code}\n" https://estian-tattler.sanity.studio/
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
studioHost: 'estian-tattler',
api: {
projectId: 'lcvgtfvq',
dataset: 'production'
},
deployment: {
appId: 'uai6f1wkpsf49r3et3gtpiio',
/**
* Enable auto-updates for studios.
* Learn more at https://www.sanity.io/docs/studio/latest-version-of-sanity#k47faf43faf56
*/
autoUpdates: true,
},
})
[32m✔[39m Deployed 1/1 schemas
↳ List deployed schemas with: sanity schema list
302
Studio and schema are deployed at estian-tattler.sanity.studio. Now I'll check it visually in the automation browser.
$ sed -n '1,60p' /c/tmp/demo-shots/verify_draft.mjs; curl -s -m 5 http://127.0.0.1:9223/json/version | head -3; curl -s -m 5 http://127.0.0.1:9222/json/version | head -3
import { connect } from '../bugsmash/cdp.mjs';
import { writeFileSync } from 'node:fs';
const PORT = 9222;
const url = process.argv[2];
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json();
const c = connect(tab.webSocketDebuggerUrl); await c.ready;
try {
await c.navigate(url, 8000);
console.log(JSON.stringify(await c.evaluate(`({title: document.querySelector('h1')?.textContent.trim(), imgs: [...document.querySelectorAll('.crayons-article__main img, .crayons-article__cover img')].map(i => i.naturalWidth), tags: [...document.querySelectorAll('.crayons-tag')].map(t => t.textContent.trim()).slice(0,6), unpublishedBanner: /unpublished|draft/i.test(document.body.innerText.slice(0,3000)), headings: [...document.querySelectorAll('.crayons-article__main h2')].map(h=>h.textContent.trim())})`)));
writeFileSync('C:/tmp/demo-shots/preview.png', Buffer.from(await c.screenshot(), 'base64'));
} finally { c.close(); await fetch(`http://127.0.0.1:${PORT}/json/close/${tab.id}`); }
{
"Browser": "Chrome/153.0.8010.54",
"Protocol-Version": "1.3",
$ grep -n "^export\|async \w*(\|^ \w*(" /c/tmp/bugsmash/cdp.mjs | head -30; for p in 9222 9223; do echo "$p: $(curl -s -m 3 http://127.0.0.1:$p/json/version | grep -c Browser)"; done
15:export async function listTabs(port = PORT) {
22:export async function findWs(urlSubstr, port = PORT) {
28:export async function connectByUrl(urlSubstr, port = PORT) {
34:export function connect(wsUrl) {
65: const evaluate = async (expression, awaitPromise = true) => {
72: const navigate = async (url, waitMs = 5000) => { await send('Page.navigate', { url }); await sleep(waitMs); };
75: const clickXY = async (x, y) => {
80: const clickSelector = async (selector) => {
89: const key = async (name, { ctrl = false } = {}) => {
108: const screenshot = async () => (await send('Page.captureScreenshot', { format: 'png' })).data;
9222: 1
9223: 0
C:\tmp\demo-shots\shot.mjs
File created successfully at: C:\tmp\demo-shots\shot.mjs (file state is current in your context — no need to Read it back)
$ grep -n "const send\|return {" /c/tmp/bugsmash/cdp.mjs | head; cd /c/tmp/demo-shots && mkdir -p tattler && timeout 90 node shot.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper;receipts" tattler/receipts.png 14000
53: const send = (method, params = {}) =>
81: const box = await evaluate(`(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e) return null; e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect(); return {x:r.x+Math.min(30,r.width/2), y:r.y+Math.min(20,r.height/2)}; })()`);
110: return { ready, send, evaluate, navigate, sleep, clickXY, clickSelector, insertText, key, setReactValue, screenshot,
https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-snake-grasshopper;receipts | Estian Tattler | Sanity
C:\tmp\demo-shots\tattler\receipts.png
[image removed — image/png, stripped by compact-transcripts]
The structure tool crashed. Running the Studio locally to get the actual error.
$ cd /d/Repos/web-apps/estian-tattler/studio && (npx sanity dev --port 3333 > /c/tmp/studio-dev.log 2>&1 &) ; sleep 25; tail -5 /c/tmp/studio-dev.log
[32m✔[39m Checking configuration files...
- Starting dev server
[32m✔[39m Starting dev server
Sanity Studio using vite@8.3.1 ready in 421ms and running at http://localhost:3333/
[2m9:29:44 PM[22m [36m[1m[vite][22m[39m [90m[2m(client)[22m[39m [optimizer] bundling dependencies...
$ cd /c/tmp/demo-shots && timeout 110 node shot.mjs "http://localhost:3333/structure/onTheDesk;story-snake-grasshopper;receipts" tattler/local.png 30000 1440 900 "(() => { const b=[...document.querySelectorAll('button')].find(b=>/Copy error/.test(b.textContent)); return document.body.innerText.slice(0,1500) })()"
"ET\nEstian Tattler\nChoose login provider\nGoogle\nGitHub\nE-mail / password\nCommunity\n•\nDocs\n•\nPrivacy\n•\nsanity.io"
http://localhost:3333/structure/onTheDesk;story-snake-grasshopper;receipts | Sanity Studio
$ sed -n '34,64p' /c/tmp/bugsmash/cdp.mjs
export function connect(wsUrl) {
const ws = new WebSocket(wsUrl);
let nextId = 1;
const pending = new Map();
const listeners = [];
const ready = new Promise((res, rej) => {
ws.addEventListener('open', () => res());
ws.addEventListener('error', (e) => rej(new Error('ws error: ' + (e.message || 'non-101 status — stale tab id?'))));
});
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
} else if (msg.method) {
for (const l of listeners) l(msg);
}
});
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
setTimeout(() => {
if (pending.has(id)) { pending.delete(id); reject(new Error('timeout: ' + method)); }
}, 30000);
});
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Evaluate an expression in the page and return its JS value.
$ sed -n '100,115p' /c/tmp/bugsmash/cdp.mjs
if (!el) return false;
const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(String(value))});
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return true;
})()`);
// Full-page screenshot → returns base64 PNG (caller writes it with fs).
const screenshot = async () => (await send('Page.captureScreenshot', { format: 'png' })).data;
return { ready, send, evaluate, navigate, sleep, clickXY, clickSelector, insertText, key, setReactValue, screenshot,
on: (fn) => listeners.push(fn), close: () => ws.close() };
}
C:\tmp\demo-shots\studio_errors.mjs
File created successfully at: C:\tmp\demo-shots\studio_errors.mjs (file state is current in your context — no need to Read it back)
$ timeout 100 node studio_errors.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk" 2>&1 | head -60
no errors
$ timeout 60 node shot.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk" tattler/desk.png 14000 >/dev/null; timeout 60 node shot.mjs "https://estian-tattler.sanity.studio/structure" tattler/root.png 14000 >/dev/null; echo ok
ok
C:\tmp\demo-shots\tattler\root.png
[image removed — image/png, stripped by compact-transcripts]
The Studio's desk structure renders. The crash is only in the document pane, and I'm tracking it down now.
C:\tmp\demo-shots\tattler\desk.png
[image removed — image/png, stripped by compact-transcripts]
$ python - <<'EOF'
p='studio_errors.mjs'
s=open(p).read()
s=s.replace("await f.evaluate('location.reload()', false).catch(() => {});","await f.evaluate(process.argv[3] || 'location.reload()', false).catch((e) => errors.push('eval ' + e.message));")
open(p,'w').write(s)
EOF
timeout 100 node studio_errors.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk" "document.querySelector('a[href*=\"story-snake\"]').click()" 2>&1 | head -80
no errors
$ sed -i "s| f = connect(frame.webSocketDebuggerUrl); await f.ready;| console.log('frame', frame.url.slice(0, 120)); f = connect(frame.webSocketDebuggerUrl); await f.ready;|; s| console.log(errors.slice(0, 8)| console.log(await f.evaluate('document.body.innerText.slice(0, 600)'));\n console.log(errors.slice(0, 8)|" studio_errors.mjs && timeout 100 node studio_errors.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk" "document.querySelector('a[href*=\"story-snake\"]')?.click() ?? 'nolink'" 2>&1 | head -60
frame https://estian-tattler.sanity.studio/structure/onTheDesk/?_context=%7B%22env%22%3A%22production%22%2C%22mode%22%3A%22cor
ET
Estian Tattler
Structure
Workflows
Vision
Releases
Drafts
The Tattler
On the desk
Snake and Grasshopper: the marriage, the split, the binges
Hearts
Published
Draft
Snake and Grasshopper: the marriage, the split, the binges
Editor
Receipts
Workflows
Story
Snake and Grasshopper: the marriage, the split, the binges
Story desk
Editor's desk
Start workflow
Headline
Dek
The line under the headline.
Section
Front page
Hearts
Science
Brawls
Commerce
Weather
Graves
Pitch
What the desk wants from the reporter.
Leads
The records the pitch starts from. The reporter can dig up more.
Snake Rato married R
no errors
The editor opens fine, so the crash must be in the Receipts view. Clicking into it to capture the error.
$ timeout 110 node studio_errors.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper" "[...document.querySelectorAll('button,a')].find(b=>b.textContent.trim()==='Receipts')?.click() ?? 'notab'" 2>&1 | tail -30
frame https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper/?_context=%7B%22env%22%3A%22production%
ET
Estian Tattler
Structure
Workflows
Vision
Releases
Drafts
The Tattler
On the desk
Snake and Grasshopper: the marriage, the split, the binges
Hearts
Published
Draft
Snake and Grasshopper: the marriage, the split, the binges
Editor
Receipts
Workflows
Passes fact-check
8 claims, 8 asides, 10 records cited
Snake and Grasshopper: the marriage, the split, the binges
Wed on Day 132, split on Day 252, and chatting again on Day 305
By claude-opus-5-5, Claude Agent SDK
Every great romance starts somewhere, and this pairing seems to have started at a party.
Aside. The paper's opinion, no receipt neede
no errors
The view works; only the deep-link URL crashed it, because the view had no id. Giving it one and redeploying.
$ cd /d/Repos/web-apps/estian-tattler/studio && sed -i "s/S.view.component(ReceiptsView).title('Receipts')/S.view.component(ReceiptsView).id('receipts').title('Receipts')/" structure.ts && grep -n receipts structure.ts && timeout 500 npx sanity deploy -y 2>&1 | tail -3
48: S.view.component(ReceiptsView).id('receipts').title('Receipts'),
[32m✔[39m Deploying to sanity.studio
Success! Studio deployed to https://estian-tattler.sanity.studio/
Now I'll take proper screenshots. I'm hiding the cookie banner in my own tab only, not dismissing it on the user's account.
$ cd /c/tmp/demo-shots && python - <<'EOF'
p='shot.mjs'
s=open(p).read()
s=s.replace(" if (js) {"," await c.evaluate(`(() => { for (const el of document.querySelectorAll('body *')) if (/utilizes technologies such as cookies/.test(el.textContent || '') && el.children.length < 8 && el.getBoundingClientRect().width < 700) { (el.closest('[role=dialog]') || el.parentElement).style.display = 'none'; break } })()`);\n if (js) {")
open(p,'w').write(s)
EOF
timeout 80 node shot.mjs "https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper%2Cview%3Dreceipts" tattler/receipts.png 16000 1600 1000
https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-snake-grasshopper%2Cview%3Dreceipts | Snake and Grasshopper: the marriage, the split, the binges | Estian Tattler | Sanity
C:\tmp\demo-shots\tattler\receipts.png
[image removed — image/png, stripped by compact-transcripts]
The Receipts view looks right: every sentence sits next to its records, with a live verdict at the top. Committing this checkpoint locally.
$ cd /d/Repos/web-apps/estian-tattler && rm -f newsroom/q.tmp.mjs newsroom/pitch.tmp.mjs && git status --short | head -40 && git status --short | grep -i "env\|token"
?? .gitignore
?? data/
?? ingest/
?? newsroom/
?? studio/
$ cd /d/Repos/web-apps/estian-tattler && git add -A && git status --short | grep -v "^A studio/schemaTypes\|^A studio/components" | head -40; git diff --cached --name-only | grep -i "\.env$\|node_modules" ; du -sh data
warning: in the working copy of '.gitignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'data/tattler.ndjson', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'ingest/parse_save.py', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/desk-runner.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/effects/fact-check.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/effects/print.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/lib/desk.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/package-lock.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/package.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/sanity.workflow.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/test/factcheck.test.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/tsconfig.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'newsroom/workflows/story-desk.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/.gitignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/README.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/components/PitchAction.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/components/ReceiptsView.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/components/annotations.tsx', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/eslint.config.mjs', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/lib/morgue.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/package-lock.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'studio/
[... 1845 more characters trimmed]
Committed. Next up is the public front page: a Next.js static export to GitHub Pages, with the printed stories and a hover receipt on every claim.
$ mkdir -p /d/Repos/web-apps/estian-tattler/frontpage && cd /d/Repos/web-apps/estian-tattler/frontpage && cat > package.json <<'EOF'
{
"name": "estian-frontpage",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"typecheck": "tsc --noEmit"
}
}
EOF
npm i next@latest react@latest react-dom@latest @sanity/client @portabletext/react 2>&1 | tail -1; npm i -D typescript @types/react @types/node 2>&1 | tail -1; grep '"version"' node_modules/next/package.json node_modules/react/package.json
found 0 vulnerabilities
found 0 vulnerabilities
node_modules/next/package.json: "version": "16.3.6",
node_modules/react/package.json: "version": "19.3.0",
$ mkdir -p app/story/\[id\] lib components && cat > next.config.ts <<'EOF'
import type {NextConfig} from 'next'
const config: NextConfig = {
output: 'export',
basePath: process.env.BASE_PATH || '',
trailingSlash: true,
images: {unoptimized: true},
}
export default config
EOF
cat > tsconfig.json <<'EOF'
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [{"name": "next"}],
"paths": {"@/*": ["./*"]}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
EOF
echo ok
ok
D:\Repos\web-apps\estian-tattler\frontpage\lib\tattler.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\frontpage\lib\tattler.ts (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\frontpage\components\StoryBody.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\frontpage\components\StoryBody.tsx (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler && curl -s 'https://lcvgtfvq.api.sanity.io/v2025-02-19/data/query/production?query=%7B%22colony%22%3A*%5B_id%3D%3D%22colony%22%5D%5B0%5D%2C%22stories%22%3A*%5B_type%3D%3D%22story%22%5D%7B_id%2Cheadline%2Csection%2CprintedAt%2Cedition%7D%7D' | head -c 1500; echo; ls studio/schemaTypes; cat studio/schemaTypes/colony.ts 2>/dev/null | head -40
{"query":"{\"colony\":*[_id==\"colony\"][0],\"stories\":*[_type==\"story\"]{_id,headline,section,printedAt,edition}}","result":{"colony":{"_createdAt":"2026-09-25T13:15:35Z","_id":"colony","_rev":"MpdCHrJeN1WypWW7HH1iE1","_type":"colony","_updatedAt":"2026-09-25T13:15:35Z","currentDay":306,"firstRecordDay":117,"gameVersion":"1.5.4297 rev1079","modCount":815,"name":"Tribe of Estian","recordCount":631},"stories":[{"_id":"story-snake-grasshopper","edition":null,"headline":"Snake and Grasshopper: the marriage, the split, the binges","printedAt":null,"section":"Hearts"}]},"syncTags":["s1:ljL9LQ","s1:P0YPHA"],"ms":4}
colony.ts
index.ts
pawn.ts
record.ts
story.ts
import {defineField, defineType} from 'sanity'
export const colony = defineType({
name: 'colony',
title: 'Colony',
type: 'document',
readOnly: true,
fields: [
defineField({name: 'name', type: 'string'}),
defineField({name: 'gameVersion', type: 'string'}),
defineField({name: 'modCount', type: 'number'}),
defineField({name: 'currentDay', type: 'number', description: 'Days since landing when the save was made.'}),
defineField({
name: 'firstRecordDay',
type: 'number',
description: 'The save forgets old tales and letters. Nothing before this day can be proven.',
}),
defineField({name: 'recordCount', type: 'number'}),
],
})
$ grep -n "name:" studio/schemaTypes/story.ts | head -40; grep -n "claims\|asides" studio/lib/factcheck.ts | head
9: name: 'story',
21: defineField({name: 'headline', type: 'string', validation: (rule) => rule.required().max(90)}),
22: defineField({name: 'dek', type: 'string', description: 'The line under the headline.'}),
23: defineField({name: 'section', type: 'string', options: {list: SECTIONS}}),
25: name: 'pitch',
31: name: 'leads',
37: name: 'body',
48: name: 'claim',
54: name: 'records',
62: name: 'aside',
67: fields: [defineField({name: 'note', type: 'string'})],
74: defineField({name: 'byline', type: 'string'}),
76: name: 'factCheck',
81: defineField({name: 'passed', type: 'boolean'}),
82: defineField({name: 'checkedAt', type: 'datetime'}),
83: defineField({name: 'claims', type: 'number'}),
84: defineField({name: 'asides', type: 'number'}),
86: name: 'problems',
91: name: 'problem',
93: defineField({name: 'text', type: 'string'}),
94: defineField({name: 'reason', type: 'string'}),
102: defineField({name: 'edition', type: 'number', readOnly: true}),
103: defineField({name: 'printedAt', type: 'datetime', readOnly: true}),
10:export type Verdict = {passed: boolean; claims: number; asides: number; problems: Problem[]}
56: let claims = 0
57: let asides = 0
68: claims++
80: asides++
93: if (claims === 0) problems.push({text: story.headline ?? '', reason: 'has no claims at all'})
94: return {passed: problems.length === 0, claims, asides, problems}
$ cd /d/Repos/web-apps/estian-tattler/frontpage && cat > app/layout.tsx <<'EOF'
import type {Metadata} from 'next'
import './tattler.css'
export const metadata: Metadata = {
title: 'The Estian Tattler',
description: 'A tabloid for one RimWorld colony. Every claim links to the save file record it came from.',
}
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
EOF
cat > components/Masthead.tsx <<'EOF'
import Link from 'next/link'
import type {Colony} from '@/lib/tattler'
export function Masthead({colony, edition}: {colony: Colony; edition?: number}) {
return (
<header className="masthead">
<div className="ears">
<span>Colony Day {colony.currentDay}</span>
<span>{edition ? `Edition ${edition}` : 'No edition yet'}</span>
<span>{colony.modCount} mods, zero corrections</span>
</div>
<Link href="/" className="nameplate">
The Estian Tattler
</Link>
<div className="strap">
Gossip for the {colony.name}. Every claim has a receipt. Hover it.
</div>
</header>
)
}
EOF
cat > app/page.tsx <<'EOF'
import Link from 'next/link'
import {Masthead} from '@/components/Masthead'
import {getPaper, slug} from '@/lib/tattler'
export default async function FrontPage() {
const {colony, stories} = await getPaper()
const [lead, ...rest] = stories
return (
<main className="paper">
<Masthead colony={colony} edition={lead?.edition} />
{lead ? (
<Link href={`/story/${slug(lead)}/`} className="splash">
{lead.section && <span className="section">{lead.section}</span>}
<h1>{lead.headline}</h1>
{lead.dek && <p className="dek">{lead.dek}</p>}
<span className="more">{lead.claims} claims, all sourced. Read it.</span>
</Link>
) : (
<p className="empty">Nothing has cleared the desk yet.</p>
)}
{rest.length > 0 && (
<section className="columns">
{rest.map((story) => (
<Link key={story._id} href={`/story/${slug(story)}/`} className="card">
{story.section && <span className="section">{story.section}</span>}
<h2>{story.headline}</h2>
{story.dek && <p className="dek">{story.dek}</p>}
</Link>
))}
</section>
)}
<HowItWorks recordCount={colony.recordCount} firstRecordDay={colony.firstRecordDay} />
</main>
)
}
function HowItWorks({recordCount, firstRecordDay}: {recordCount: number; firstRecordDay: number}) {
return (
<footer className="colophon">
<h3>How this paper works</h3>
<p>
The newsroom is a Sanity dataset of {recordCount} records pulled out of one RimWorld save: tales, letters,
messages and pawn-to-pawn talk. The save forgets anything older than day {firstRecordDay}, so nothing earlier
can be printed.
</p>
<p>
A Claude reporter drafts each story with only a search tool over those records. Every sentence is either a
claim that cites records or an aside with no names and no numbers in it. A fact-checker, which is plain code and
not a model, rejects any claim naming a pawn or quoting a number its receipts don't contain. Failed drafts
go back to the reporter, three strikes and the story is spiked. An editor still has to approve it in Sanity
Studio before the press runs.
</p>
</footer>
)
}
EOF
cat > "app/story/[id]/page.tsx" <<'EOF'
import {notFound} from 'next/navigation'
import {Masthead} from '@/components/Masthead'
import {ReceiptLine, StoryBody} from '@/components/StoryBody'
import {getPaper, getStory, receiptsOf, slug} from '@/lib/tattler'
export const dynamicParams = false
export async function generateStaticParams() {
const {stories} = await getPaper()
return stories.map((story) => ({id: slug(story)}))
}
export default async function StoryPage({params}: {params: Promise<{id: string}>}) {
const {id} = await params
const [{colony}, story] = await Promise.all([getPaper(), getStory(`story-${id}`)])
if (!story) notFound()
const receipts = receiptsOf(story)
return (
<main className="paper">
<Masthead colony={colony} edition={story.edition} />
<article className="story">
{story.section && <span className="section">{story.section}</span>}
<h1>{story.headline}</h1>
{story.dek && <p className="dek">{story.dek}</p>}
<p className="byline">
By {story.byline}. Edition {story.edition}.
</p>
<StoryBody story={story} />
<details className="evidence">
<summary>All {receipts.length} records this story cites</summary>
<ol>
{receipts.map((r) => (
<li key={r._id}>
<ReceiptLine r={r} />
</li>
))}
</ol>
</details>
</article>
</main>
)
}
EOF
echo ok
ok
$ sed -i 's/{colony.modCount} mods, zero corrections/{colony.modCount} mods loaded/' components/Masthead.tsx && grep -n "mods" components/Masthead.tsx
10: <span>{colony.modCount} mods loaded</span>
The front page's data layer and pages are in place, so next up is the stylesheet with the hover receipts. After that I'll print the first story so there's something to build against.
D:\Repos\web-apps\estian-tattler\frontpage\app\tattler.css
File created successfully at: D:\Repos\web-apps\estian-tattler\frontpage\app\tattler.css (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/newsroom && npx sanity-workflows show --help 2>&1 | head -20; ls
Show the state, activities, and effects of a workflow instance.
USAGE
$ sanity-workflows show INSTANCEID [--tag <value>] [--include
history...] [--json]
ARGUMENTS
INSTANCEID Workflow instance document id.
FLAGS
--include=<option>... [default: ] Optional sections to include in rendered
output (--json always carries the full document).
<options: history>
--json Emit structured JSON instead of rendered output.
--tag=<value> Workflow environment tag (e.g. prod, test) — an
optional query filter that also narrows which resources
are searched; omit to span them all.
DESCRIPTION
Show the state, activities, and effects of a workflow instance.
desk-runner.ts
effects
lib
node_modules
package-lock.json
package.json
sanity.workflow.ts
test
tsconfig.json
workflows
$ set -a && . ./.env && set +a && npx sanity-workflows --help 2>&1 | sed -n '1,40p'
Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.
VERSION
@sanity/workflow-cli/0.35.0 win32-x64 node-v22.18.0
USAGE
$ sanity-workflows [COMMAND]
COMMANDS
help
Display help for sanity-workflows.
abort
Abort an in-flight workflow instance — a hard stop: pending effects are
cancelled, stage guards removed, and the instance is marked terminal where
it stands.
blueprint generate
Experimental: generate the Sanity Blueprints runtime the definitions
require, next to sanity.workflow.ts. Writes the workflow resources, one
function per derived need, the effect-handler registry, and a handler stub
per declared effect. Covers every deployment in the config, because the
emitted resources module declares them all. These flags and this output may
change before the Blueprints backend accepts the sanity.workflow resource.
definition delete
Delete a deployed workflow definition (every version, or one via --version).
Refuses while non-terminal instances exist unless --cascade aborts them
first — instances are aborted in place, never deleted.
definition diff
Diff an in-code definition against the deployed version (latest by default).
definition list
List deployed workflow definitions.
definition show
Show a deployed workflow definition.
deploy
$ set -a && . ./.env && set +a && npx sanity-workflows --help 2>&1 | sed -n '40,90p' | grep -E "^ [a-z]"; npx sanity-workflows list --help 2>&1 | head -5
deploy
diagnose
fire-action
list
nuke
reset-activity
set-stage
show
start
tail
List workflow instances in the configured dataset (in-flight by default).
USAGE
$ sanity-workflows list [--tag <value>] [--include-completed]
[--failed] [--definition <value>] [--document <value>] [--assignment-user
$ set -a && . ./.env && set +a && npx sanity-workflows list --tag production 2>&1 | head -20
instance workflow tag stage status updated
production.wf-instance.a46f134ebcc0 story-desk production editor in-flight 18 minutes ago
$ set -a && . ./.env && set +a && npx sanity-workflows fire-action production.wf-instance.a46f134ebcc0 --activity review --action approve 2>&1 | tail -5 && npm run desk -- --once 2>&1 | tail -15 && npx sanity-workflows show production.wf-instance.a46f134ebcc0 2>&1 | head -25
[32m✔[39m Fired approve on review — now at printing, then cascaded 1 auto-transition(s)
ops applied:
[34mℹ[39m field.set → stage.decision
[34mℹ[39m status.set
> estian-newsroom@1.0.0 desk
> tsx --env-file=.env desk-runner.ts --once
[effect.print] printed story-snake-grasshopper in edition 1
[effect.print] GITHUB_TOKEN not set, front page not rebuilt
production.wf-instance.a46f134ebcc0 print done (13s)
story-desk v1 production.wf-instance.a46f134ebcc0
Stage: printed
Started: 2026-09-25 21:22:34
Completed: 2026-09-25 21:42:11
Tag: production
Stages:
• reporting (exited 2026-09-25 21:23:51)
[32m✔[39m report [done]
• fact-check (exited 2026-09-25 21:24:02)
[32m✔[39m check [done]
• editor (exited 2026-09-25 21:41:51)
[32m✔[39m review [done]
• printing (exited 2026-09-25 21:42:11)
[32m✔[39m press [done]
• printed (current)
The whole pipeline ran end to end and the first story is printed. Building the front page against it now.
$ cd /d/Repos/web-apps/estian-tattler/frontpage && npx tsc --noEmit 2>&1 | head -20; npx next build 2>&1 | tail -25
- include was updated to add '.next/dev/types/**/*.ts'
The following mandatory changes were made to your tsconfig.json:
- esModuleInterop was set to true (requirement for SWC / babel)
Finished TypeScript in 230ms ...
Collecting page data using 5 workers ...
Generating static pages using 5 workers (0/4) ...
Generating static pages using 5 workers (1/4)
Generating static pages using 5 workers (2/4)
Generating static pages using 5 workers (3/4)
✓ Generating static pages using 5 workers (4/4) in 1024ms
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
└ /story/[id]
└ ● /story/snake-grasshopper
○ (Static) prerendered as static content
● (SSG) prerendered as static HTML (uses generateStaticParams)
$ cd /d/Repos/web-apps/estian-tattler/frontpage/out && (python -m http.server 8811 >/dev/null 2>&1 &) ; sleep 2; cd /c/tmp/demo-shots && node shot.mjs http://127.0.0.1:8811/ tattler/front.png 3000 1280 900 && node shot.mjs http://127.0.0.1:8811/story/snake-grasshopper/ tattler/story.png 3000 1280 1000 "(() => { const c = document.querySelectorAll('.claim')[1]; c.focus(); return c.textContent.slice(0,200) })()"
Command did not complete within its 60s timeout and was moved to the background (ID: b4le2v6wf). Output is being written to: C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\b4le2v6wf.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains D:\Repos\web-apps\estian-tattler\frontpage; directory changes made by the backgrounded command do not apply to subsequent commands.
$ cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/b4le2v6wf.output"; ls -la /c/tmp/demo-shots/tattler/
http://127.0.0.1:8811/ | The Estian Tattler
"Just a week later, on Day 132, Snake married Grasshopper, who took his name and became Grasshopper Rato. Day 132, 00h tale Snake Rato married Reiraborvas 'Grasshopper' Rato."
http://127.0.0.1:8811/story/snake-grasshopper/ | The Estian Tattler
total 276
drwxr-xr-x 1 owner 197609 0 Sep 25 21:43 .
drwxr-xr-x 1 owner 197609 0 Sep 25 21:34 ..
-rw-r--r-- 1 owner 197609 38848 Sep 25 21:32 desk.png
-rw-r--r-- 1 owner 197609 23556 Sep 25 21:42 front.png
-rw-r--r-- 1 owner 197609 10919 Sep 25 21:30 local.png
-rw-r--r-- 1 owner 197609 128975 Sep 25 21:37 receipts.png
-rw-r--r-- 1 owner 197609 33914 Sep 25 21:33 root.png
-rw-r--r-- 1 owner 197609 32011 Sep 25 21:43 story.png
C:\tmp\demo-shots\tattler\front.png
[image removed — image/png, stripped by compact-transcripts]
C:\tmp\demo-shots\tattler\story.png
[image removed — image/png, stripped by compact-transcripts]
The front page renders the way I wanted: a tabloid nameplate, and each red-underlined claim opens its save-file receipt on hover or focus. Next I'm setting up the GitHub Pages deploy.
$ cd /d/Repos/web-apps/estian-tattler && git status --short | head; ls; cat .gitignore; gh repo view Booyaka101/estian-tattler 2>&1 | head -2
?? frontpage/
data
frontpage
ingest
newsroom
studio
node_modules/
.env
*.local
dist/
.sanity/
.next/
out/
GraphQL: Could not resolve to a Repository with the name 'Booyaka101/estian-tattler'. (repository)
$ echo "next-env.d.ts" >> .gitignore && mkdir -p .github/workflows && cat > .github/workflows/frontpage.yml <<'EOF'
name: Front page
on:
push:
branches: [main]
paths: [frontpage/**, .github/workflows/frontpage.yml]
repository_dispatch:
types: [print]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontpage
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: frontpage/package-lock.json
- run: npm ci
- run: npm run build
env:
BASE_PATH: /${{ github.event.repository.name }}
- uses: actions/upload-pages-artifact@v4
with:
path: frontpage/out
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
EOF
cat frontpage/package.json
{
"name": "estian-frontpage",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@portabletext/react": "^8.0.1",
"@sanity/client": "^8.7.0",
"next": "^16.3.6",
"react": "^19.3.0",
"react-dom": "^19.3.0"
},
"devDependencies": {
"@types/node": "^26.6.2",
"@types/react": "^19.3.0",
"typescript": "^7.0.2"
}
}
$ ls -la data ingest; git log --oneline | head; head -30 ingest/*.ts 2>/dev/null | head -60
data:
total 216
drwxr-xr-x 1 owner 197609 0 Sep 25 21:10 .
drwxr-xr-x 1 owner 197609 0 Sep 25 21:44 ..
-rw-r--r-- 1 owner 197609 214386 Sep 25 21:13 tattler.ndjson
ingest:
total 17
drwxr-xr-x 1 owner 197609 0 Sep 25 21:13 .
drwxr-xr-x 1 owner 197609 0 Sep 25 21:44 ..
-rw-r--r-- 1 owner 197609 635 Sep 25 21:12 def_labels.json
-rw-r--r-- 1 owner 197609 11575 Sep 25 21:13 parse_save.py
981129a Story desk: save ingest, Studio with receipts, workflow and newsroom
$ head -40 ingest/parse_save.py; grep -n "def \|argv\|\.rws\|gz" ingest/parse_save.py | head -30; git ls-files | head -50
"""Turn a RimWorld save into Sanity NDJSON: one colony, the pawns it remembers, and every record.
A record is one thing the save can prove happened: a tale, a letter, a message or a
conversation from the play log. Stories in the Tattler may only claim what records say.
"""
import argparse
import gzip
import json
import os
import re
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
TICKS_PER_DAY = 60000
TICKS_PER_HOUR = 2500
GAME = os.environ.get("RIMWORLD_DIR", r"D:\SteamLibrary\steamapps\common\RimWorld")
LABELS = Path(__file__).resolve().parent / "def_labels.json"
WORKSHOP = os.environ.get("WORKSHOP_DIR", r"C:\tmp\steamcmd\steamapps\workshop\content\294100")
# What each tale means, in the paper's words. Tales carry no text of their own.
TALE_VERBS = {
"AttendedParty": "{0} attended a party thrown by {1}",
"Aurora": "{0} saw an aurora",
"Breakup": "{0} broke up with {1}",
"CaravanFormed": "{0} set out with a caravan",
"CompletedLongConstructionProject": "{0} finished building {s}",
"CompletedLongCraftingProject": "{0} finished crafting {s}",
"Eclipse": "{0} saw an eclipse",
"Exhausted": "{0} collapsed from exhaustion",
"FinishedResearchProject": "{0} finished researching {s}",
"GainedMasterSkillWithPassion": "{0} became a master of {s}",
"HeatstrokeRevealed": "{0} came down with heatstroke",
"HypothermiaRevealed": "{0} came down with hypothermia",
"LandedInPod": "{0} landed in a drop pod",
"Marriage": "{0} married {1}",
"PlayedGame": "{0} played a game",
"ReadBook": "{0} read a book",
"SocialFight": "{0} got into a fist fight with {1}",
"TradedWith": "{0} traded with {1}",
7:import gzip
51:def clean(text):
55:def when(tick, start):
61:def pawn_name(snapshot):
68:def short(name):
72:def full(name):
79:def humanize(def_name):
85:def def_labels(wanted):
109:def parse(save_path, scan_defs=False):
110: opener = gzip.open if str(save_path).endswith(".gz") else open
119: def remember(pawn_id, name, snapshot=None, tick=0):
176:def pawn_doc(pid, p):
189:def record_doc(r, pawns, labels):
217:def main():
222: ap.add_argument("--scan-defs", action="store_true", help="look up def labels in the installed game and mods")
239: f"days {colony['firstRecordDay']}-{colony['currentDay']}, {len(labels)}/{len({r['subjectDef'] for r in records if r.get('subjectDef')})} def labels -> {args.out}")
.gitattributes
.gitignore
data/tattler.ndjson
inges
[... 840 more characters trimmed]
{"description":"Extract Path Two build timeline","prompt":"Read the Claude Code session transcript at C:\\Users\\owner\\.claude\\projects\\C--Users-owner\\cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl (J...
Async agent launched successfully. (This tool result is internal metadata — never quote or paste any part of it, including the agentId below, into a user-facing reply.)
agentId: ac668978203ed9620 (internal ID - do not mention to user. Use SendMessage with to: 'ac668978203ed9620', summary: '<5-10 word recap>' to continue this agent.)
The agent is working in the background. You will be notified automatically when it completes. You know nothing about its results until that notification arrives — do not report, assume, or predict them; continue other work or respond to the user in the meantime.
Do not duplicate this agent's work — avoid working with the same files or topics it is using.
output_file: C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\ac668978203ed9620.output
Do NOT Read or tail this file via the shell tool — it is the full subagent JSONL transcript and reading it will overflow your context. If the user asks for progress, say the agent is still running; you'll get a completion notification.
While that runs I'll queue more stories. First I'm checking the shape of the existing pitch so the new ones match it.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');
const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'});
c.fetch('*[_id in [\"story-snake-grasshopper\"]]{_id,section,pitch,leads,headline,_type}').then(r=>console.log(JSON.stringify(r,null,1).slice(0,1500)))
"; grep -n "SECTIONS" -A3 ../studio/schemaTypes/story.ts | head -8
[
{
"_id": "story-snake-grasshopper",
"_type": "story",
"headline": "Snake and Grasshopper: the marriage, the split, the binges",
"leads": [
{
"_key": "recordtale554",
"_ref": "record-tale-554",
"_type": "reference"
},
{
"_key": "recordtale1036",
"_ref": "record-tale-1036",
"_type": "reference"
},
{
"_key": "recordletter581",
"_ref": "record-letter-581",
"_type": "reference"
},
{
"_key": "recordletter604",
"_ref": "record-letter-604",
"_type": "reference"
},
{
"_key": "recordtalk14672",
"_ref": "record-talk-14672",
"_type": "reference"
}
],
"pitch": "They married on Day 132 and split on Day 252. Grasshopper binged twice after the divorce. Are they talking again?",
"section": "Hearts"
}
]
6:export const SECTIONS = ['Front page', 'Hearts', 'Science', 'Brawls', 'Commerce', 'Weather', 'Graves']
7-
8-export const story = defineType({
9- name: 'story',
--
23: defineField({name: 'section', type: 'string', options: {list: SECTIONS}}),
24- defineField({
25- name: 'pitch',
$ set -a && . ./.env && set +a && cat > /c/tmp/q.cjs <<'EOF'
const {createClient}=require('D:/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/client');
const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',useCdn:false});
const [q, ...rest]=process.argv.slice(2);
c.fetch(q, rest[0]?JSON.parse(rest[0]):{}).then(r=>{for (const x of r) console.log(typeof x==='string'?x:JSON.stringify(x))})
EOF
node /c/tmp/q.cjs '*[_type=="record" && (text match "berr*" || label match "berr*" || text match "Embaixada" || text match "Rocaniraalbo" || text match "Bladeroot" || kind=="letter")]|order(tick asc){_id,colonyDay,hour,kind,label,"t":text[0..160]}' | head -120
node:internal/modules/cjs/loader:1368
throw err;
^
Error: Cannot find module 'D:/Repos/web-apps/estian-tattler/newsroom/node_modules/@sanity/client'
Require stack:
- C:\tmp\q.cjs
at Function._resolveFilename (node:internal/modules/cjs/loader:1365:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1021:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1026:22)
at Function._load (node:internal/modules/cjs/loader:1175:37)
at TracingChannel.traceSync (node:diagnostics_channel:322:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:235:24)
at Module.require (node:internal/modules/cjs/loader:1445:12)
at require (node:internal/modules/helpers:135:16)
at Object.<anonymous> (C:\tmp\q.cjs:1:22)
at Module._compile (node:internal/modules/cjs/loader:1688:14) {
code: 'MODULE_NOT_FOUND',
requireStack: [ 'C:\\tmp\\q.cjs' ]
}
Node.js v22.18.0
C:\tmp\q.py
File created successfully at: C:\tmp\q.py (file state is current in your context — no need to Read it back)
$ python /c/tmp/q.py '*[_type=="record" && (text match "berr*" || text match "Embaixada" || text match "Rocaniraalbo" || text match "Bladeroot" || kind=="letter")]|order(tick asc){_id,colonyDay,hour,kind,label,"t":text[0..150]}' | head -150
{"_id": "record-tale-504", "colonyDay": 117, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-505", "colonyDay": 117, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-506", "colonyDay": 117, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-507", "colonyDay": 117, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-508", "colonyDay": 117, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-510", "colonyDay": 119, "hour": 5, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-511", "colonyDay": 119, "hour": 5, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-513", "colonyDay": 119, "hour": 15, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-515", "colonyDay": 120, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-517", "colonyDay": 120, "hour": 13, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-518", "colonyDay": 121, "hour": 21, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-519", "colonyDay": 123, "hour": 0, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-520", "colonyDay": 123, "hour": 5, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-521", "colonyDay": 123, "hour": 7, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-523", "colonyDay": 123, "hour": 22, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-527", "colonyDay": 125, "hour": 0, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-529", "colonyDay": 125, "hour": 6, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-530", "colonyDay": 125, "hour": 6, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-533", "colonyDay": 125, "hour": 21, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-534", "colonyDay": 126, "hour": 11, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-537", "colonyDay": 127, "hour": 2, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-539", "colonyDay": 128, "hour": 7, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-541", "colonyDay": 128, "hour": 18, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-545", "colonyDay": 130, "hour": 4, "kind": "tale", "label": null, "t": null}
{"_id": "record-tale-546", "colonyDay": 131, "hour": 6, "kind": "tale", "label": null, "t": null}
{"_id": "rec
[... 12513 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler && grep -n "name:" studio/schemaTypes/record.ts; python /c/tmp/q.py '*[_id=="record-tale-547"][0]'
5: name: 'record',
11: name: 'kind',
16: defineField({name: 'text', type: 'text', rows: 3}),
17: defineField({name: 'label', type: 'string'}),
18: defineField({name: 'colonyDay', type: 'number'}),
19: defineField({name: 'hour', type: 'number'}),
20: defineField({name: 'pawns', type: 'array', of: [defineArrayMember({type: 'reference', to: [{type: 'pawn'}]})]}),
21: defineField({name: 'def', type: 'string', description: "The game's def for the tale, letter or interaction."}),
22: defineField({name: 'subjectDef', type: 'string'}),
23: defineField({name: 'subjectLabel', type: 'string'}),
24: defineField({name: 'letterClass', type: 'string'}),
25: defineField({name: 'tick', type: 'number'}),
26: defineField({name: 'sourceId', type: 'string', description: 'ID of the entry inside the save.'}),
28: orderings: [{title: 'Colony day', name: 'day', by: [{field: 'tick', direction: 'asc'}]}],
{"_createdAt": "2026-09-25T13:15:35Z", "_id": "record-tale-547", "_rev": "G6X5Q7T5x7WwfPCCQHWjyE", "_type": "record", "_updatedAt": "2026-09-25T13:15:38Z", "colonyDay": 131, "def": "SocialFight", "hour": 14, "kind": "tale", "pawns": [{"_key": "Human147640", "_ref": "pawn-Human147640", "_type": "reference"}, {"_key": "Human250142", "_ref": "pawn-Human250142", "_type": "reference"}], "sourceId": "547", "text": "Hyena Embaixada got into a fist fight with Reiraborvas 'Grasshopper' Canga.", "tick": 7880038}
$ python /c/tmp/q.py '*[_type=="record" && kind in ["letter","message"]]|order(tick asc){_id,colonyDay,hour,label,text}' | cut -c1-260 | head -90
{"_id": "record-message-1392", "colonyDay": 264, "hour": 20, "label": null, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-letter-581", "colonyDay": 265, "hour": 1, "label": "Food binge: Grasshopper", "text": "Grasshopper is pigging out on food.\n\nThis happened because of poor mood.\n\nThe final straw was: Divorced by Snake"}
{"_id": "record-message-1393", "colonyDay": 265, "hour": 1, "label": null, "text": "Fresh carrion spotted: Red panda (dead)"}
{"_id": "record-message-1395", "colonyDay": 265, "hour": 3, "label": null, "text": "Grasshopper has been successfully calmed down by Embaixada."}
{"_id": "record-message-1396", "colonyDay": 265, "hour": 4, "label": null, "text": "Grasshopper is no longer pigging out on food."}
{"_id": "record-message-1394", "colonyDay": 265, "hour": 4, "label": null, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1397", "colonyDay": 265, "hour": 5, "label": null, "text": "Snake started a social fight with Rocaniraalbo."}
{"_id": "record-message-1400", "colonyDay": 265, "hour": 6, "label": null, "text": "Snake and Rocaniraalbo are no longer social fighting."}
{"_id": "record-message-1401", "colonyDay": 265, "hour": 8, "label": null, "text": "Fresh carrion spotted: Turkey (dead)"}
{"_id": "record-message-1402", "colonyDay": 265, "hour": 18, "label": null, "text": "Your restaurant has earned $12 today."}
{"_id": "record-message-1403", "colonyDay": 266, "hour": 6, "label": null, "text": "Fresh carrion spotted: Buck (dead)"}
{"_id": "record-letter-582", "colonyDay": 266, "hour": 8, "label": "Gift from Grand Elders", "text": "Tribespeople from Grand Elders have left a gift:\n\n -Crossbow (normal)"}
{"_id": "record-message-1404", "colonyDay": 266, "hour": 8, "label": null, "text": "Visitors from Grand Elders are leaving."}
{"_id": "record-message-1405", "colonyDay": 266, "hour": 8, "label": null, "text": "They are absolutely amazed by your hospitality! (Score: 100) They will visit again in a few days."}
{"_id": "record-message-1406", "colonyDay": 266, "hour": 10, "label": null, "text": "Fresh carrion spotted: Velociraptor (dead)"}
{"_id": "record-message-1407", "colonyDay": 266, "hour": 11, "label": null, "text": "Tribal gathering has begun."}
{"_id": "record-message-1408", "colonyDay": 266, "hour": 12, "label": null, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1410", "colonyDay": 266, "hour": 12, "label": null, "text": "Fresh carrion sp
[... 11604 more characters trimmed]
I'm picking leads for three more stories out of the save records: the berry rot, the brawls, and the restaurant and visitor scores.
$ python /c/tmp/q.py '*[_type=="record" && (text match "rotted" || text match "Berry bush" || text match "restaurant" || text match "Score" || text match "fist fight" || text match "social fight" || _id in ["record-tale-504","record-tale-505","record-tale-506","record-tale-507","record-tale-508"])]|order(tick asc){_id,colonyDay,hour,text}' | cut -c1-200
{"_id": "record-tale-504", "colonyDay": 117, "hour": 11, "text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."}
{"_id": "record-tale-505", "colonyDay": 117, "hour": 11, "text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."}
{"_id": "record-tale-506", "colonyDay": 117, "hour": 11, "text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."}
{"_id": "record-tale-507", "colonyDay": 117, "hour": 11, "text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."}
{"_id": "record-tale-508", "colonyDay": 117, "hour": 11, "text": "Reiraborvas 'Grasshopper' Canga was wounded by Hyena Embaixada."}
{"_id": "record-tale-547", "colonyDay": 131, "hour": 14, "text": "Hyena Embaixada got into a fist fight with Reiraborvas 'Grasshopper' Canga."}
{"_id": "record-message-1392", "colonyDay": 264, "hour": 20, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1394", "colonyDay": 265, "hour": 4, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1397", "colonyDay": 265, "hour": 5, "text": "Snake started a social fight with Rocaniraalbo."}
{"_id": "record-message-1402", "colonyDay": 265, "hour": 18, "text": "Your restaurant has earned $12 today."}
{"_id": "record-tale-1070", "colonyDay": 265, "hour": 23, "text": "Snake Rato got into a fist fight with Rocaniraalbo Ambmea."}
{"_id": "record-message-1405", "colonyDay": 266, "hour": 8, "text": "They are absolutely amazed by your hospitality! (Score: 100) They will visit again in a few days."}
{"_id": "record-message-1408", "colonyDay": 266, "hour": 12, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1412", "colonyDay": 267, "hour": 3, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1414", "colonyDay": 267, "hour": 18, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1415", "colonyDay": 267, "hour": 23, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1422", "colonyDay": 269, "hour": 7, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1423", "colonyDay": 269, "hour": 9, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1424", "colonyDay": 269, "hour": 18, "text": "Apples x74 has rotted away in storage."}
{"_id": "record-message-1426", "colonyDay": 269, "hour": 20, "text": "Berries x75 has rotted away in storage."}
{"_id": "record-message-1428", "col
[... 5021 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');
const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false});
const ref=id=>({_type:'reference',_ref:id,_key:id.replace(/-/g,'')});
const pitches=[
{_id:'story-berry-rot',section:'Front page',headline:'The berry pile keeps rotting',pitch:'From Day 264 on, storage reports berries rotting away over and over, and berry bushes die unharvested. How much went to waste, and did anyone notice?',leads:['record-message-1392','record-message-1428','record-message-1465','record-message-1545','record-message-1529']},
{_id:'story-fist-fights',section:'Brawls',headline:'Who hits whom in Estian',pitch:'Hyena Embaixada wounded Grasshopper on Day 117 and fist-fought her on Day 131. Then on Day 265, the night of her binge, Embaixada was the one who calmed her down, and Snake got into a fight with Rocaniraalbo.',leads:['record-tale-504','record-tale-547','record-message-1395','record-message-1397','record-tale-1070']},
{_id:'story-restaurant-reviews',section:'Commerce',headline:'The restaurant: takings and reviews',pitch:'The colony runs a restaurant. Daily takings swing from \$11 to \$56, and visitor scores run from 100 down to -12. Who stopped coming back?',leads:['record-message-1402','record-message-1546','record-message-1405','record-message-1558','record-message-1537']},
];
const tx=c.transaction();
for (const p of pitches) tx.createIfNotExists({...p,_type:'story',leads:p.leads.map(ref)});
tx.commit().then(r=>console.log('ok',r.results.length));
"
ok 3
$ set -a && . ./.env && set +a && for id in story-berry-rot story-fist-fights story-restaurant-reviews; do npx sanity-workflows start story-desk --tag production --field "subject={\"id\":\"dataset:lcvgtfvq:production:$id\",\"type\":\"story\"}" 2>&1 | tail -2; done
- Starting story-desk…
[32m✔[39m Started production.wf-instance.e171c19d651e — now at reporting
- Starting story-desk…
[32m✔[39m Started production.wf-instance.abe4269a17dc — now at reporting
- Starting story-desk…
[32m✔[39m Started production.wf-instance.a37f315f7d77 — now at reporting
$ npm run desk -- --once 2>&1 | tail -30
> estian-newsroom@1.0.0 desk
> tsx --env-file=.env desk-runner.ts --once
[effect.draft-story] filed "The berry pile keeps rotting" after 10 tool calls
[effect.fact-check] passed: 10 claims, 0 problems
production.wf-instance.e171c19d651e draft-story done (86s)
production.wf-instance.e171c19d651e fact-check done (86s)
[effect.draft-story] filed "Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo" after 8 tool calls
[effect.fact-check] passed: 12 claims, 0 problems
production.wf-instance.abe4269a17dc draft-story done (73s)
production.wf-instance.abe4269a17dc fact-check done (73s)
[effect.draft-story] filed "Eastern Republic sours on the restaurant: "Score: -12"" after 6 tool calls
[effect.fact-check] passed: 8 claims, 0 problems
production.wf-instance.a37f315f7d77 draft-story done (60s)
production.wf-instance.a37f315f7d77 fact-check done (60s)
$ set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');
const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'});
c.fetch('*[_id in [\"drafts.story-restaurant-reviews\",\"drafts.story-fist-fights\",\"drafts.story-berry-rot\"]]{_id,headline,dek,body[]{children[]{text,marks},markDefs[]{_key,_type,\"r\":records[]->{colonyDay,hour,text}}}}').then(rs=>{for(const s of rs){console.log('\n#####',s._id,'|',s.headline,'|',s.dek);for(const b of s.body){for(const ch of b.children){const d=b.markDefs.find(m=>ch.marks.includes(m._key));console.log(d?._type==='claim'?'C':'A',ch.text.trim());if(d?.r)for(const r of d.r)console.log(' > D'+r.colonyDay+' '+r.hour+'h '+r.text.replace(/\n+/g,' / ').slice(0,160))}console.log('--')}}})
"
##### drafts.story-berry-rot | The berry pile keeps rotting | From Day 264 to Day 296, storage reported berries rotting away again and again, and the bushes outside died unpicked
A Something in the tribal larder smells, and it isn't the gossip.
--
C On Day 264, storage sent its first notice: Berries x75 has rotted away in storage.
> D264 20h Berries x75 has rotted away in storage.
C It was only the start: by Day 269, nine reports had come in, every time another stack of 75 berries gone bad.
> D264 20h Berries x75 has rotted away in storage.
> D265 4h Berries x75 has rotted away in storage.
> D266 12h Berries x75 has rotted away in storage.
> D267 3h Berries x75 has rotted away in storage.
> D267 18h Berries x75 has rotted away in storage.
> D267 23h Berries x75 has rotted away in storage.
> D269 7h Berries x75 has rotted away in storage.
> D269 9h Berries x75 has rotted away in storage.
> D269 20h Berries x75 has rotted away in storage.
C On Day 269 the apples joined in too, with Apples x74 rotting away.
> D269 18h Apples x74 has rotted away in storage.
--
C All told, between Day 264 and Day 296 storage filed 39 separate reports of berries rotting away.
> D264 20h Berries x75 has rotted away in storage.
> D265 4h Berries x75 has rotted away in storage.
> D266 12h Berries x75 has rotted away in storage.
> D267 3h Berries x75 has rotted away in storage.
> D267 18h Berries x75 has rotted away in storage.
> D267 23h Berries x75 has rotted away in storage.
> D269 7h Berries x75 has rotted away in storage.
> D269 9h Berries x75 has rotted away in storage.
> D269 20h Berries x75 has rotted away in storage.
> D271 11h Berries x15 has rotted away in storage.
> D271 11h Berries x60 has rotted away in storage.
> D271 12h Berries x60 has rotted away in storage.
> D273 6h Berries x11 has rotted away in storage.
> D278 0h Berries x40 has rotted away in storage.
> D278 7h Berries x65 has rotted away in storage.
> D278 14h Berries x75 has rotted away in storage.
> D279 5h Berries x75 has rotted away in storage.
> D279 17h Berries x75 has rotted away in storage.
> D280 9h Berries x75 has rotted away in storage.
> D281 9h Berries x75 has rotted away in storage.
> D286 10h Berries x57 has rotted away in storage.
> D286 19h Berries x70 has rotted away in storage.
> D288 3h Berries x50 has rotted away in stora
[... 9555 more characters trimmed]
$ python /c/tmp/q.py '*[_type=="pawn" && (name match "Embaixada" || shortName match "Embaixada")]{_id,name,shortName,aliases,gender,relationNote}'; python /c/tmp/q.py '*[_type=="record" && (label match "binge" || text match "binge" || text match "pigging" || text match "berr*" && kind=="talk")]{_id,colonyDay,kind,label,text}' | cut -c1-200; python /c/tmp/q.py '*[_type=="record" && colonyDay==305 && hour==6]{_id,text}' | cut -c1-200
{"_id": "pawn-Human147640", "aliases": ["Embaixada", "Hyena", "Hyena Embaixada"], "gender": "Male", "name": "Hyena Embaixada", "relationNote": null, "shortName": "Hyena"}
{"_id": "record-letter-581", "colonyDay": 265, "kind": "letter", "label": "Food binge: Grasshopper", "text": "Grasshopper is pigging out on food.\n\nThis happened because of poor mood.\n\nThe final st
{"_id": "record-letter-604", "colonyDay": 277, "kind": "letter", "label": "Food binge: Grasshopper", "text": "Grasshopper is pigging out on food.\n\nThis happened because of poor mood.\n\nThe final st
{"_id": "record-letter-632", "colonyDay": 290, "kind": "letter", "label": "Bladeroot binge: Embaixada", "text": "Embaixada, Thane is binging on Bladeroot.\n\nThis happened because of poor mood.\n\nThe
{"_id": "record-message-1396", "colonyDay": 265, "kind": "message", "label": null, "text": "Grasshopper is no longer pigging out on food."}
{"_id": "record-message-1457", "colonyDay": 278, "kind": "message", "label": null, "text": "Grasshopper is no longer pigging out on food."}
{"_id": "record-letter-690", "text": "Plebeians from Eastern Republic have left a gift:\n\n -Wooden lute (normal)\n -Concrete lyre (normal)"}
{"_id": "record-message-1557", "text": "Visitors from Eastern Republic are leaving."}
{"_id": "record-message-1558", "text": "They expected more from you. (Score: -12) They won't come back any time soon."}
{"_id": "record-talk-14667", "text": "Charango Paphianne chatted with Iola 'Smalls' Schaefer."}
$ set -a && . ./.env && set +a && npx sanity-workflows fire-action production.wf-instance.e171c19d651e --activity review --action send-back --param "note=Cut 'the only food drama on file' and 'the records show nobody remarking on it'. You can prove what a record says, not that nothing else exists. Say what the records show and leave the absence as a question." 2>&1 | tail -4 && npm run desk -- --once 2>&1 | tail -6
[34mℹ[39m field.set → stage.decision
[34mℹ[39m field.set → workflow.deskNote
[34mℹ[39m field.set → workflow.drafts
[34mℹ[39m status.set
> tsx --env-file=.env desk-runner.ts --once
[effect.draft-story] filed "The berry pile keeps rotting" after 9 tool calls
[effect.fact-check] passed: 9 claims, 0 problems
production.wf-instance.e171c19d651e draft-story done (92s)
production.wf-instance.e171c19d651e fact-check done (92s)
$ set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');
const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'});
c.fetch('*[_id==\"drafts.story-berry-rot\"][0]{headline,dek,body[]{children[]{text,marks},markDefs[]{_key,_type,\"n\":count(records)}}}').then(s=>{console.log(s.headline,'|',s.dek);for(const b of s.body){for(const ch of b.children){const d=b.markDefs.find(m=>ch.marks.includes(m._key));console.log(d?._type==='claim'?'C'+d.n:'A',ch.text.trim())}console.log('--')}})
"
The berry pile keeps rotting | From Day 264 to Day 296, storage kept reporting berries rotted away, while berry bushes died left unharvested
C1 It started on Day 264, when storage reported that Berries x75 had rotted away.
A Then it happened again. And again.
--
C39 Between Day 264 and Day 296, the colony logged berries rotting away in storage 39 times.
C9 The first nine reports were all full stacks of 75.
--
C5 Day 289 was the worst of it, with five separate reports: 30, 75, 75, 72 and 60 berries gone.
C1 The berries weren't alone: Apples x74 rotted away in storage on Day 269.
C1 The last berry report came on Day 296, a mere 10.
--
C2 Out in the fields, a berry bush died from rotting after being left unharvested on Day 270, and another on Day 280.
C3 A raspberry bush died from rotting on Day 294, another raspberry bush died unharvested on Day 298, and a berry bush followed on Day 300.
A Too many berries in the larder and nobody picking the bushes? The Tattler can't square it either.
--
C2 Meanwhile, Grasshopper went on a food binge on Day 265 and again on Day 277, both times after being divorced by Snake.
A Did anybody in the tribe notice the smell from storage, or say a word about it? If they did, it hasn't reached this desk. Tips welcome.
--
The rewrite dropped both unprovable lines and still passed the fact-check. Now I'll try approving through the Studio's Workflows panel rather than the CLI, so the demo shows the editor side for real.
$ grep -o 'https://[^" ]*view%3Dreceipts[^" ]*' "C:/Users/owner/.claude/projects/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl" | head -3
https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper%2Cview%3Dreceipts\
https://estian-tattler.sanity.studio/structure/onTheDesk;story-snake-grasshopper%2Cview%3Dreceipts\
https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-snake-grasshopper%2Cview%3Dreceipts
$ cd /c/tmp/demo-shots && timeout 90 node studio_errors.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-fist-fights" "void 0" 2>&1 | tail -30
Front page
Eastern Republic sours on the restaurant: "Score: -12"
Commerce
Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo
Brawls
Published
Draft
Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo
Editor
Receipts
Workflows
Story
Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo
Story desk
Editor's desk
Start workflow
Headline
Dek
The line under the headline.
Section
Front page
Hearts
Science
Brawls
Commerce
Weather
Graves
Pitch
What the desk wan
no errors
C:\tmp\demo-shots\studio_do.mjs
File created successfully at: C:\tmp\demo-shots\studio_do.mjs (file state is current in your context — no need to Read it back)
$ timeout 110 node studio_do.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-fist-fights" tattler/wf1.png "(() => { const t = [...document.querySelectorAll('button,a,[role=tab]')].find((e) => e.textContent.trim() === 'Workflows'); t?.click(); return !!t })()" "document.querySelector('[data-testid=document-panel-scroller], [data-ui=DocumentPanel]')?.innerText?.slice(0,1500) || [...document.querySelectorAll('[data-testid]')].map(e=>e.dataset.testid).filter((v,i,a)=>a.indexOf(v)===i).slice(0,80).join(' ')" 2>&1 | tail -10
> true
> "studio-layout studio-navbar new-document-button studio-search tool-collapse-menu collapse-tab-list-placeholder releases-tool-link release-avatar-caution global-perspective-menu-button tasks-toolbar button-resources-menu default-preview Media default-preview__header new-document-button-search-input release-menu spinner menu-button-resources menu-item-studio-version"
C:\tmp\demo-shots\tattler\wf1.png
[image removed — image/png, stripped by compact-transcripts]
$ python - <<'EOF'
import re
p='studio_do.mjs'
s=open(p).read()
start=s.index(' await c.evaluate(`(() => { const all')
end=s.index('\n', s.index('el.style.display = \'none\' } })()`);'))
s=s[:start]+""" await c.evaluate(`(() => { const b = [...document.querySelectorAll('button')].find((x) => /reject non-essential/i.test(x.textContent));
let el = b; while (el && getComputedStyle(el).position !== 'fixed') el = el.parentElement; if (el) el.style.display = 'none'; return !!el })()`);"""+s[end:]
open(p,'w').write(s)
EOF
grep -n "reject" studio_do.mjs; timeout 110 node studio_do.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/workflows" tattler/wf2.png "(() => { const r = [...document.querySelectorAll('tr,[role=row],a')].find((e) => /Who hits whom/.test(e.textContent) && e.textContent.length < 300); r?.click(); return r?.tagName })()" "document.body.innerText.slice(0,2500)" 2>&1 | tail -40
14: await c.evaluate(`(() => { const b = [...document.querySelectorAll('button')].find((x) => /reject non-essential/i.test(x.textContent));
> "TR"
> "ET\nEstian Tattler\nStructure\nWorkflows\nVision\nReleases\nDrafts\nWorkflows\nStart workflow\nOverview\nFor me\nAll workflows\nOpen runs\nAdd filter\nTable\nBoard\n3 open runs\nWorkflow subject\n\t\nWorkflow\n\t\nStage\n\t\nStarted ↓\n\t\nTasks\n\n\nEastern Republic sours on the restaurant: \"Score: -12\"\nCommerce\n\t\nStory desk\n\t\nEditor's desk\n\t\n9m ago\n\t\n\n\nWho hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo\nBrawls\n\t\nStory desk\n\t\nEditor's desk\n\t\n9m ago\n\t\n\n\nThe berry pile keeps rotting\nFront page\n\t\nStory desk\n\t\nEditor's desk\n\t\n9m ago\n\t\nStory desk / Editor's desk\nWorkflow subject\nWho hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo\nBrawls\nWorkflow\nStory:\nWho hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo\nDrafts filed:\n1\nNote for the reporter\nReporting:\n100%\nReporting\nFact-check\nEditor's desk\nOn the press\nPrinted\nSpiked\nDecision\nRead it and decide\nSelect action\nHistory\nG\ng-GhSvcjjlfvuL entered Editor's desk from Fact-check\n·\n6m ago\nG\ng-GhSvcjjlfvuL moved Fact-check → Editor's desk\n·\n6m ago\nG\ng-GhSvcjjlfvuL left Fact-check for Editor's desk\n·\n6m ago\nG\ng-GhSvcjjlfvuL changed state via action Checked\n·\n6m ago\nG\ng-GhSvcjjlfvuL changed Check every sentence against its receipts: active → done\n·\n6m ago\nThe workflow fired Checked on Check every sentence against its receipts\n·\n6m ago\nShow all history\nManage\nCancel workflow…\nSeven updates that give people and agents mor
C:\tmp\demo-shots\tattler\wf2.png
[image removed — image/png, stripped by compact-transcripts]
$ python - <<'EOF'
p='studio_do.mjs'
s=open(p).read()
a=s.index(" // The cookie notice")
b=s.index(" const targets")
s=s[:a]+s[b:]
s=s.replace(" if (out !== '-') writeFileSync", """ // The cookie notice lives in the dashboard page (maybe in a shadow root), outside the iframe; hide it in this tab only.
console.log('banner hidden:', await c.evaluate(`(() => { const roots = [document]; for (let i = 0; i < roots.length; i++) for (const e of roots[i].querySelectorAll('*')) if (e.shadowRoot) roots.push(e.shadowRoot);
for (const r of roots) { const b = [...r.querySelectorAll('button')].find((x) => /reject non-essential/i.test(x.textContent));
let el = b; while (el && getComputedStyle(el).position !== 'fixed') el = el.parentElement || el.getRootNode().host; if (el) { el.style.display = 'none'; return true } } return false })()`));
if (out !== '-') writeFileSync""")
open(p,'w').write(s)
EOF
timeout 120 node studio_do.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/workflows" tattler/wf3.png "(() => { const r = [...document.querySelectorAll('tr')].find((e) => /Who hits whom/.test(e.textContent)); r?.click(); return !!r })()" "(() => { const b = [...document.querySelectorAll('button')].find((e) => /Select action/.test(e.textContent)); b?.click(); return !!b })()" "[...document.querySelectorAll('[role=menuitem],[role=option]')].map(e => e.innerText).join(' | ')" 2>&1 | tail
> true
> true
> "Diagnostics | Sanity StudioUp to datev6.16.0 | Documentation | Changelog | What's new | Join our community | Account support | Contact sales | Definitions | Documentation | All workflows3 runs | Story desk3 runs | Open runs | Closed runs | Unassigned3 runs | Current user | AAdministrator | BDBlueprints Deployer | CContributor | DDeveloper | EEditor | VViewer | Christo | Blocked | Overdue | No release3 runs | Send to press | Send back… | Spike it"
banner hidden: true
C:\tmp\demo-shots\tattler\wf3.png
[image removed — image/png, stripped by compact-transcripts]
The action dialog offers "Send to press", "Send back…" and "Spike it". I'll approve this story from the Studio, then fix the banner by hiding every match with !important.
$ sed -i "s/if (el) { el.style.display = 'none'; return true } } return false/if (el) { el.style.setProperty('display', 'none', 'important'); n++ } } return n/; s/const roots = \[document\]; for/let n = 0; const roots = [document]; for/" studio_do.mjs && grep -n "n++" studio_do.mjs | head -2 && timeout 120 node studio_do.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/workflows" tattler/wf4.png "(() => { const r = [...document.querySelectorAll('tr')].find((e) => /Who hits whom/.test(e.textContent)); r?.click(); return !!r })()" "(() => { const b = [...document.querySelectorAll('button')].find((e) => /Select action/.test(e.textContent)); b?.click(); return !!b })()" "(() => { const b = [...document.querySelectorAll('button')].find((e) => e.textContent.trim() === 'Send to press'); b?.click(); return !!b })()" "document.body.innerText.match(/History[\\s\\S]{0,400}/)?.[0]" 2>&1 | tail -5
24: let el = b; while (el && getComputedStyle(el).position !== 'fixed') el = el.parentElement || el.getRootNode().host; if (el) { el.style.setProperty('display', 'none', 'important'); n++ } } return n })()`));
> true
> true
> true
> "History\nG\ng-GhSvcjjlfvuL entered Editor's desk from Fact-check\n·\n8m ago\nG\ng-GhSvcjjlfvuL moved Fact-check → Editor's desk\n·\n8m ago\nG\ng-GhSvcjjlfvuL left Fact-check for Editor's desk\n·\n8m ago\nG\ng-GhSvcjjlfvuL changed state via action Checked\n·\n8m ago\nG\ng-GhSvcjjlfvuL changed Check every sentence against its receipts: active → done\n·\n8m ago\nThe workflow fired Checked on Check every sentence against its rec"
banner hidden: 1
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows list --tag production 2>&1 | head
instance workflow tag stage status updated
production.wf-instance.abe4269a17dc story-desk production printing in-flight 37 seconds ago
production.wf-instance.e171c19d651e story-desk production editor in-flight 6 minutes ago
production.wf-instance.a37f315f7d77 story-desk production editor in-flight 8 minutes ago
C:\tmp\demo-shots\tattler\wf4.png
[image removed — image/png, stripped by compact-transcripts]
$ python /c/tmp/q.py '{"tale":count(*[_type=="record"&&kind=="tale"]),"letter":count(*[_type=="record"&&kind=="letter"]),"message":count(*[_type=="record"&&kind=="message"]),"talk":count(*[_type=="record"&&kind=="talk"]),"pawns":count(*[_type=="pawn"])}'; cd /d/Repos/web-apps/estian-tattler && grep -n "argv\|add_argument" ingest/parse_save.py; cat newsroom/.env | sed 's/=.*/=.../'
{"letter": 53, "message": 149, "pawns": 17, "tale": 354, "talk": 75}
219: ap.add_argument("save")
220: ap.add_argument("--colony", default="Tribe of Estian")
221: ap.add_argument("--out", default=str(Path(__file__).resolve().parent.parent / "data" / "tattler.ndjson"))
222: ap.add_argument("--scan-defs", action="store_true", help="look up def labels in the installed game and mods")
SANITY_TOKEN=...
D:\Repos\web-apps\estian-tattler\README.md
File created successfully at: D:\Repos\web-apps\estian-tattler\README.md (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\BUILDLOG.md
File created successfully at: D:\Repos\web-apps\estian-tattler\BUILDLOG.md (file state is current in your context — no need to Read it back)
$ python - <<'EOF'
p='BUILDLOG.md'
s=open(p,encoding='utf-8').read()
rep=[
("with one instruction, to go all out, and didn't pick the idea or review the code while it was being written.","with one instruction, to go all out. They didn't pick the idea or write any of it."),
("The first idea was a deal desk for the owner's resale business. I dropped it because it overlapped with a trading tool the owner had already retired and asked me not to bring back.","My first idea overlapped with a project the owner had already shut down, so I dropped it."),
("- Pawn names came from the first snapshot of a pawn in the save, so a colonist who married showed her maiden name everywhere. It now takes the latest.","- Pawn names came from the first snapshot of a pawn in the save, not the latest, so a pawn could show a name they no longer went by. It now takes the latest."),
("- Sanity document ids with a dot in them are treated as non-public, so the first import's ids would have hidden everything from the public API. Ids are hyphenated: `record-tale-547`, `pawn-Human147640`.","- Sanity treats document ids with a dot in them as non-public, and the front page reads the public API with no token. Ids are hyphenated: `record-tale-547`, `pawn-Human147640`."),
]
for a,b in rep:
assert a in s, a[:40]
s=s.replace(a,b)
open(p,'w',encoding='utf-8').write(s)
EOF
grep -c "—" README.md BUILDLOG.md; rm studio/README.md; git status --short
README.md:0
BUILDLOG.md:0
M .gitignore
D studio/README.md
?? .github/
?? BUILDLOG.md
?? README.md
?? frontpage/
$ sed -i "s/^- Twice, writing files through a shell heredoc ate backslashes, once in a regex and once in a \`'\\\\n'\` join\./- Three times, writing files through a shell heredoc ate backslashes: in a regex, in a \`'\\\\n'\` join, and in a Python string. /" BUILDLOG.md && grep -n "heredoc" BUILDLOG.md; ls frontpage;
63:- Twice, writing files through a shell heredoc ate backslashes, once in a regex and once in a `'\n'` join. Those files get written with an editor tool now.
app
components
lib
next-env.d.ts
next.config.ts
node_modules
out
package-lock.json
package.json
tsconfig.json
tsconfig.tsbuildinfo
D:\Repos\web-apps\estian-tattler\BUILDLOG.md
The file D:\Repos\web-apps\estian-tattler\BUILDLOG.md has been updated successfully. (file state is current in your context — no need to Read it back)
$ echo "*.tsbuildinfo" >> .gitignore && mkdir -p docs && cp /c/tmp/demo-shots/tattler/story.png docs/story.png && cp /c/tmp/demo-shots/tattler/wf4.png docs/workflows.png && cp /c/tmp/demo-shots/tattler/receipts.png docs/receipts.png && git add -A && git status --short && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -q -m "Front page, README and build log
Static Next.js front page that shows the records behind every claim,
a Pages workflow the press triggers, and docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git log --oneline | head -2
warning: in the working copy of 'frontpage/tsconfig.json', CRLF will be replaced by LF the next time Git touches it
A .github/workflows/frontpage.yml
M .gitignore
A BUILDLOG.md
A README.md
A docs/receipts.png
A docs/story.png
A docs/workflows.png
A frontpage/app/layout.tsx
A frontpage/app/page.tsx
A frontpage/app/story/[id]/page.tsx
A frontpage/app/tattler.css
A frontpage/components/Masthead.tsx
A frontpage/components/StoryBody.tsx
A frontpage/lib/tattler.ts
A frontpage/next.config.ts
A frontpage/package-lock.json
A frontpage/package.json
A frontpage/tsconfig.json
D studio/README.md
9b2729f Front page, README and build log
981129a Story desk: save ingest, Studio with receipts, workflow and newsroom
Committed. Before the repo goes public I'm scanning every tracked file for anything that looks like a secret.
$ git grep -nIE "sk[a-zA-Z0-9]{20,}|ghp_|gho_|github_pat|sk-ant|SANITY_TOKEN=|owner|C:\\\\Users" $(git rev-list --all) -- . ':!*.ndjson' | head; git grep -nIE "owner|Users" HEAD -- data/tattler.ndjson | head -3; echo "---"; git ls-files | xargs du -ch 2>/dev/null | tail -1
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:frontpage/package-lock.json:784: "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:newsroom/package-lock.json:1451: "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:newsroom/package-lock.json:1594: "integrity": "sha512-62q5RMKskByqbUZtJUbdzhN4Li5C8ZHrb+zL6Mc0DKjD7xh5EG6VMY5TMJL2YGHlNEAniy4AChqSJPPv5jY+kg==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:newsroom/package-lock.json:2016: "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:newsroom/package-lock.json:3725: "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:newsroom/package-lock.json:5204: "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:studio/package-lock.json:348: "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:studio/package-lock.json:401: "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:studio/package-lock.json:431: "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
9b2729f579e4597aa6a7c35a123a2d6ab6deb0fe:studio/package-lock.json:457: "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
---
1.4M total
$ git grep -nIE "ghp_|gho_|github_pat|sk-ant|skp[A-Za-z0-9]{30}|SANITY_TOKEN=|owner|Users\\\\" $(git rev-list --all) -- . ':!*package-lock.json' | head; echo "exit $?"
exit 0
D:\Repos\web-apps\estian-tattler\docs\receipts.png
[image removed — image/png, stripped by compact-transcripts]
The old receipts screenshot still has the cookie banner in it, so I'm retaking it with the fixed script.
$ cd /c/tmp/demo-shots && timeout 100 node studio_do.mjs "https://www.sanity.io/@oj47h4o89/studio/uai6f1wkpsf49r3et3gtpiio/default/structure/onTheDesk;story-berry-rot%2Cview%3Dreceipts" tattler/receipts2.png "document.title" 2>&1 | tail -2
> "The berry pile keeps rotting | Estian Tattler"
banner hidden: 1
C:\tmp\demo-shots\tattler\receipts2.png
[image removed — image/png, stripped by compact-transcripts]
The banner's gone and the Receipts view comes out clean. Next I'm swapping it in, creating the public repo, and enabling Pages.
$ cd /d/Repos/web-apps/estian-tattler && cp /c/tmp/demo-shots/tattler/receipts2.png docs/receipts.png && git add docs/receipts.png && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -q -m "Retake the receipts screenshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git branch -M main && gh repo create Booyaka101/estian-tattler --public --source . --push --description "A RimWorld colony tabloid where every sentence cites the save-file record that proves it. Sanity Workflows + Claude Agent SDK." 2>&1 | tail -3 && gh api -X POST repos/Booyaka101/estian-tattler/pages -f build_type=workflow 2>&1 | head -5
branch 'main' set up to track 'origin/main'.
To https://github.com/Booyaka101/estian-tattler.git
* [new branch] HEAD -> main
{"url":"https://api.github.com/repos/Booyaka101/estian-tattler/pages","status":null,"cname":null,"custom_404":false,"html_url":"https://booyaka101.github.io/estian-tattler/","build_type":"workflow","source":{"branch":"main","path":"/"},"public":true,"protected_domain_state":null,"pending_domain_unverified_at":null,"https_enforced":true}
$ cd /d/Repos/web-apps/estian-tattler/newsroom && grep -q '^GITHUB_TOKEN=' .env || echo "GITHUB_TOKEN=$(gh auth token)" >> .env; sed 's/=.*/=.../' .env; set -a && . ./.env && set +a && for i in e171c19d651e a37f315f7d77; do npx sanity-workflows fire-action production.wf-instance.$i --activity review --action approve 2>&1 | grep -E "Fired|rror"; done; npm run desk -- --once 2>&1 | tail -8
SANITY_TOKEN=...
GITHUB_TOKEN=...
[32m✔[39m Fired approve on review — now at printing, then cascaded 1 auto-transition(s)
[32m✔[39m Fired approve on review — now at printing, then cascaded 1 auto-transition(s)
> tsx --env-file=.env desk-runner.ts --once
[effect.print] printed story-berry-rot in edition 2
production.wf-instance.e171c19d651e print done (14s)
[effect.print] printed story-fist-fights in edition 3
production.wf-instance.abe4269a17dc print done (13s)
[effect.print] printed story-restaurant-reviews in edition 4
production.wf-instance.a37f315f7d77 print done (13s)
$ cd /d/Repos/web-apps/estian-tattler && grep -n "dispatch\|log(" newsroom/effects/print.ts | head; gh run list -R Booyaka101/estian-tattler --limit 6
20: ctx.log(`printed ${id} in edition ${edition}`)
24: if (!token) return ctx.log('GITHUB_TOKEN not set, front page not rebuilt')
25: const res = await fetch(`https://api.github.com/repos/${REPO}/dispatches`, {
30: if (!res.ok) throw new Error(`GitHub dispatch failed: ${res.status} ${await res.text()}`)
in_progress print Front page main repository_dispatch 36144830466 8s 2026-09-25T14:02:24Z
completed cancelled print Front page main repository_dispatch 36144807711 14s 2026-09-25T14:02:11Z
completed cancelled print Front page main repository_dispatch 36144781738 29s 2026-09-25T14:01:57Z
The press fired a rebuild for each story, and the newer runs cancelled the older ones as designed. I'm waiting on the last run now.
$ for i in $(seq 1 30); do s=$(gh run view 36144830466 -R Booyaka101/estian-tattler --json status,conclusion -q '.status+" "+.conclusion'); case "$s" in completed*) break;; esac; sleep 10; done; echo "$s"; gh run view 36144830466 -R Booyaka101/estian-tattler --log-failed 2>/dev/null | tail -20
completed success
$ sleep 5; curl -s https://booyaka101.github.io/estian-tattler/ | grep -o '<h[12][^>]*>[^<]*' | head; cd /c/tmp/demo-shots && node shot.mjs https://booyaka101.github.io/estian-tattler/ tattler/live-front.png 4000 1280 1100 && node shot.mjs https://booyaka101.github.io/estian-tattler/story/fist-fights/ tattler/live-story.png 4000 1280 1000 "(() => { const c = document.querySelectorAll('.claim')[4]; c.focus(); return c.textContent.slice(0,120) })()"
<h1>Eastern Republic sours on the restaurant: "Score: -12"
<h2>Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo
<h2>The berry pile keeps rotting
<h2>Snake and Grasshopper: the marriage, the split, the binges
https://booyaka101.github.io/estian-tattler/ | The Estian Tattler
"Now to Day 265, the ugly one. A letter that day says Grasshopper was pigging out on food because of poor mood, and the f"
https://booyaka101.github.io/estian-tattler/story/fist-fights/ | The Estian Tattler
C:\tmp\demo-shots\tattler\live-front.png
[image removed — image/png, stripped by compact-transcripts]
C:\tmp\demo-shots\tattler\live-story.png
[image removed — image/png, stripped by compact-transcripts]
D:\Repos\web-apps\estian-tattler\frontpage\app\tattler.css
The file D:\Repos\web-apps\estian-tattler\frontpage\app\tattler.css has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler && cp /c/tmp/demo-shots/tattler/live-front.png docs/front.png && git add -A && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -q -m "Quieter claim underlines, front page screenshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git push -q 2>&1 | tail -2; sleep 5; gh run list -R Booyaka101/estian-tattler --limit 2
in_progress Quieter claim underlines, front page screenshot Front page main push 36145136428 3s 2026-09-25T14:05:12Z
completed success print Front page main repository_dispatch 36144830466 56s 2026-09-25T14:02:24Z
Now I'm updating memory with the Path Two state so it survives the next compaction.
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity/workflow-engine && cat package.json | python -c "import json,sys;d=json.load(sys.stdin);print(d['version'],list(d.get('exports',{}).keys()))"; ls dist | head; grep -oE "^export (declare )?(function|const|class|type|interface) [A-Za-z]+" dist/index.d.ts 2>/dev/null | head -80
0.35.0 ['.', './define', './package.json']
_chunks-cjs
_chunks-es
define.cjs
define.d.cts
define.d.ts
define.js
index.cjs
index.d.cts
index.d.ts
index.js
export declare interface AbortInstanceArgs
export declare function abortReason
export declare function acceptsDocumentType
export declare type AcknowledgedWorkflowDeployment
export declare function aclPathForResource
export declare function aclPathForResource
export declare type Action
export declare const ACTION
export declare function actionDisabledDetail
export declare class ActionDisabledError
export declare type ActionDisabledReason
export declare interface ActionEvaluation
export declare type ActionName
export declare type ActionParam
export declare class ActionParamsInvalidError
export declare function actionRendering
export declare type ActionSemantic
export declare function actionVerdict
export declare function activeAssignmentMembers
export declare type Activity
export declare const ACTIVITY
export declare const ACTIVITY
export declare function activityAutonomyOf
export declare interface ActivityDescription
export declare interface ActivityEntry
export declare interface ActivityEvaluation
export declare type ActivityKind
export declare type ActivityName
export declare type ActivityStatus
export declare interface Actor
export declare const ACTOR
export declare function actorFulfillsRole
export declare type ActorKind
export declare function actorMatchesAssignment
export declare type ActorResolution
export declare const ANONYMOUS
export declare interface ApplicabilitySource
export declare function applicableDefinitions
export declare function assertReadableModel
export declare function assertReaderModelAcknowledgement
export declare type Assignee
export declare interface AssignmentIdentity
export declare type AssignmentMatch
export declare function assignmentMatch
export declare function assignmentMembers
export declare function assignmentPrefilter
export declare type AssignmentState
export declare function assignmentState
export declare interface AssignmentStateCounts
export declare function assignmentStateCounts
export declare const AUTHORING
export declare type AuthoringAction
export declare type AuthoringActivity
export declare type AuthoringEditable
export declare type AuthoringEffect
export declare type AuthoringEffectRetry
export declare type AuthoringFieldEntry
export declare type AuthoringFieldRef
export declare type AuthoringGuard
export declare type AuthoringManualTarget
export declare
[... 762 more characters trimmed]
$ grep -nE "^export declare (interface|type) (WorkflowEngine|Engine)\b" -A60 dist/index.d.ts | grep -E "^\s*[0-9]+-\s+(readonly )?[a-zA-Z]+\??[:(<]" | head -40; ls ../ | grep -iE "sdk|workflow"
3644- readonly client: WorkflowClient;
3645- readonly tag: string;
3646- readonly workflowResource: WorkflowResource;
3647- readonly effects: ResolvedEngineEffects;
3648- readonly logger: LoggerFactory;
3651- readonly telemetry: WorkflowTelemetryLogger;
3653- resolveActor: (
3654- args: ResolveClientActorArgs,
3659- deployDefinitions: <T extends WorkflowDefinitionInput<T>>(
3660- args: DeployDefinitionsArgs<T>,
3666- startInstance: (args: StartInstanceArgs) => Promise<OperationResult>;
3670- fireAction: (args: FireActionArgs) => Promise<OperationResult>;
3673- editField: (args: EditFieldArgs) => Promise<OperationResult>;
3677- completeEffect: (args: CompleteEffectArgs) => Promise<OperationResult>;
3681- commitEffectOps: (args: CommitEffectOpsArgs) => Promise<OperationResult>;
3686- tick: (args: OperationArgs) => Promise<OperationResult>;
3689- evaluate: (args: EvaluateArgs) => Promise<WorkflowEvaluation>;
3692- diagnose: (args: EvaluateArgs) => Promise<DiagnoseResult>;
3695- availableActions: (args: EvaluateArgs) => Promise<AvailableActionsResult>;
3697- setStage: (args: SetStageArgs) => Promise<OperationResult>;
3699- abortInstance: (args: AbortInstanceArgs) => Promise<OperationResult>;
sdk
sdk-react
workflow-components
workflow-diagram
workflow-engine
workflow-react
workflow-sdk
workflow-studio
workflow-studio-plugin
There are @sanity/workflow-react, workflow-sdk and workflow-components packages as well, which look built for App SDK apps. Checking those.
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity && for p in workflow-react workflow-sdk workflow-components; do echo "== $p $(python -c "import json;print(json.load(open('$p/package.json'))['version'])")"; ls $p; grep -oE "^export (declare )?(function|const) [A-Za-z]+" $p/dist/index.d.ts 2>/dev/null | head -40 | tr '\n' ' '; echo; done; head -80 workflow-react/README.md 2>/dev/null
== workflow-react 0.35.0
CHANGELOG.md
LICENSE
README.md
dist
node_modules
package.json
export declare function editFieldTarget export declare function isInvalidDoc export declare function useDocumentWorkflows export declare function useInstancePreviews export declare function useRefcountedIds export declare function useWorkflowInstances export declare function useWorkflowSession export declare function useWorkflowTelemetry export declare const WorkflowSessionStarted
== workflow-sdk 0.35.0
CHANGELOG.md
LICENSE
README.md
dist
package.json
export declare function sdkProjectUserDirectory export declare function useDocumentWorkflows export declare function useProjectMembers export declare function useWorkflowEngine export declare function useWorkflowInstances export declare function useWorkflowSession export declare function WorkflowTelemetryProvider
== workflow-components 0.35.0
CHANGELOG.md
LICENSE
README.md
dist
node_modules
package.json
# @sanity/workflow-react
Store-agnostic React orchestration for the `@sanity/workflow-engine` reactive
session. You supply a `WorkflowObserver` (how to watch documents in _your_
store); the hooks do the rest:
- `useWorkflowSession` — one instance, live: feeds the engine session
ready-gated and returns `{evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}`.
A settled commit re-evaluates immediately — no store echo needed.
- `useWorkflowInstances` — a live, filterable instance list (definition /
stage / document / completed) for list, board, and inbox surfaces. Returns
`{instances, loading, unreadable, error}` — rows the read discipline
refuses are skipped from `instances` and flagged in `unreadable`, so one
bad row can't sink the listing and a silently short list stays impossible.
Rows are committed instance docs — feed one to `useWorkflowSession` for
its evaluation and verdicts.
- `useInstancePreviews` — every run in the tag partition as the engine's
light preview projection, drained page by page until the whole filtered set
is held (`drained: true` means counts are totals) and then kept fresh from
the change feed: an event refetches the touched previews alone, and a feed
`reset` re-drains the whole partition. Returns
`{previews, loading, drained, unreadable, error}`.
- `useDocumentWorkflows` — which in-flight instances reference this document
(GDR URI), matched by the engine's exact watch-set semantics
(`instanceWatchesDocument`). Re
[... 2851 more characters trimmed]
$ cat workflow-sdk/README.md | head -150; python -c "import json;d=json.load(open('workflow-sdk/package.json'));print(d.get('peerDependencies'),d.get('dependencies'))"; head -40 workflow-components/README.md
# @sanity/workflow-sdk
Reactive workflow adapter over the **App SDK** store. `useWorkflowSession`
drives an `@sanity/workflow-engine` session from `getDocumentState`
(optimistic, per-doc) and returns
`{evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}`.
`useWorkflowInstances` / `useDocumentWorkflows` are live instance lists
(filterable collection / per-document discovery) read from the engine's state
dataset via `getQueryState`, each returning `{instances, loading, unreadable, error}` —
rows the read discipline refuses are skipped from `instances` and flagged in
`unreadable`, so one bad row can't sink the listing.
The session's `invalid` is the explicit validation state (a model-ahead doc
needs a newer `@sanity/workflow-engine`; a shape violation is a corrupt
stored tree) — surfaced as state instead of a throw, never `ready` while set.
SDK state-source throws are normalized into the same reactive observer state;
`error` clears when a later live emission succeeds.
```tsx
const engine = useMemo(() => createEngine({client, workflowResource, tag}), [client])
const {evaluation, ready, guards, fireAction} = useWorkflowSession({engine, instanceId})
const {instances, loading} = useDocumentWorkflows({engine, document: subjectGdrUri})
```
## Installation
Workflows SDK and Studio integrations require `@sanity/sdk` 3.1 or later in
the 3.x line. The React entry of `@sanity/workflow-sdk` also requires the
matching `@sanity/sdk-react` version.
SDK 3.1.0 allows `@sanity/mutate` 0.18.1, which can leave document reads
pending with Sanity client 8. Before installing or upgrading Workflows,
configure your application to select Mutate 0.18.2 for SDK 3. An existing
lockfile can otherwise retain 0.18.1 even after upgrading the SDK.
For npm, merge this fragment into your application's root `package.json`:
```json
{
"overrides": {
"@sanity/sdk": {
"@sanity/mutate": "0.18.2"
}
}
}
```
For pnpm, merge this fragment into the root `pnpm-workspace.yaml`:
```yaml
overrides:
'@sanity/sdk@3>@sanity/mutate': 0.18.2
```
Run your package manager's install command to update the lockfile, then
commit both configuration and lockfile. With npm, inspect the installed
dependency tree:
```sh
npm install
npm ls @sanity/sdk @sanity/mutate
```
For pnpm, run `pnpm install` and `pnpm why @sanity/mutate`.
Verify that SDK 3 resolves Mutate 0.18.2. Other dependency branches can use
their own Mutate versi
[... 11462 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler && timeout 300 npx -y sanity@latest init --template app-quickstart --organization oj47h4o89 --output-path nightdesk --typescript --package-manager npm --no-git -y 2>&1 | tail -25
[32m✔[39m You are logged in as owner@example.com using GitHub
- Bootstrapping files from template
[32m✔[39m Bootstrapping files from template
- Resolving latest module versions
[32m✔[39m Resolving latest module versions
- Creating default project files
[32m✔[39m Creating default project files
- Running npm install
[32m✔[39m Running npm install
[32m✔[39m Success! Your custom app has been scaffolded.
(cd D:\Repos\web-apps\estian-tattler\nightdesk to navigate to your new project directory)
Next, configure the project(s) and dataset(s) your app should work with in `src/App.tsx`.
Refer to our documentation for a walkthrough:
https://www.sanity.io/docs/app-sdk/sdk-configuration
Other helpful commands:
npx sanity docs browse to open the documentation in a browser
npx sanity dev to start the development server for your app
npx sanity deploy to deploy your app
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && ls -a; cat package.json sanity.cli.ts; find src -type f | head; cat src/App.tsx; cat src/*.tsx | head -80
.
..
.claude
.gitignore
AGENTS.md
README.md
eslint.config.mjs
node_modules
package-lock.json
package.json
sanity.cli.ts
src
tsconfig.json
{
"name": "nightdesk",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "package.json",
"license": "UNLICENSED",
"scripts": {
"build": "sanity build",
"deploy": "sanity deploy",
"dev": "sanity dev",
"start": "sanity start"
},
"keywords": [
"sanity"
],
"dependencies": {
"@sanity/sdk": "^2",
"@sanity/sdk-react": "^2",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@sanity/eslint-config-studio": "^7",
"@types/react": "^19.2.14",
"eslint": "^10.8.1",
"prettier": "^3.5",
"sanity": "^6.16.0",
"typescript": "^5.8"
}
}
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'oj47h4o89',
entry: './src/App.tsx',
},
})
src/App.css
src/App.tsx
src/ExampleComponent.css
src/ExampleComponent.tsx
import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
import {ExampleComponent} from './ExampleComponent'
import './App.css'
function App() {
// apps can access many different projects or other sources of data
const sanityConfigs: SanityConfig[] = [
{
projectId: '',
dataset: '',
},
]
return (
<div className="app-container">
<SanityApp config={sanityConfigs} fallback={<div>Loading...</div>}>
{/* add your own components here! */}
<ExampleComponent />
</SanityApp>
</div>
)
}
export default App
import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
import {ExampleComponent} from './ExampleComponent'
import './App.css'
function App() {
// apps can access many different projects or other sources of data
const sanityConfigs: SanityConfig[] = [
{
projectId: '',
dataset: '',
},
]
return (
<div className="app-container">
<SanityApp config={sanityConfigs} fallback={<div>Loading...</div>}>
{/* add your own components here! */}
<ExampleComponent />
</SanityApp>
</div>
)
}
export default App
import {useCurrentUser, type CurrentUser} from '@sanity/sdk-react'
import './ExampleComponent.css'
export function ExampleComponent() {
const user: CurrentUser | null = useCurrentUser()
return (
<div className="example-container">
{user?.profileImage ? (
<di
[... 1610 more characters trimmed]
$ cat AGENTS.md | head -60; ls -R .claude | head; npm view @sanity/sdk-react version; npm view @sanity/sdk version
# AGENTS.md
Guidance for AI coding agents working in this repository.
## What this is
A React application built with the Sanity App SDK (`@sanity/sdk-react`). It is not a Sanity Studio. The app reads and writes content in a Sanity project through SDK hooks, and runs inside the organization's Sanity Dashboard, in development and when deployed. The `sanity` CLI runs it with Vite under the hood.
## Key files
- `src/App.tsx`: entry point. The `SanityApp` component takes a `config` array with `projectId` and `dataset`. All SDK hooks must be used inside `SanityApp`.
- `sanity.cli.ts`: CLI config with the organization ID and app entry path.
## Commands
- `npm run dev`: starts the dev server on port 3333, but the app only renders inside the Sanity Dashboard. The CLI prints a Dashboard URL to open. Viewing it requires a signed-in Sanity account, so a human must complete authentication in the browser.
- `npm run build`: production build.
- `npm run deploy`: deploy to the Sanity Dashboard.
Environment variables prefixed with `SANITY_APP_` are bundled into the app.
## Deploying without prompts
For the standard App SDK config, create the app explicitly on the first deploy:
```bash
npm run deploy -- --create --title "My App" --yes --json
```
If `app` uses `defineApplication`, omit `--create`; that deployment flow already creates the app on its first run.
For the standard config, `--create` works even when the organization already has other apps. `app.title` in `sanity.cli.ts` can supply the title instead of `--title`. Add `--dry-run` to preview the deployment without creating or uploading anything.
Save `application.id` from the JSON response as `deployment.appId` in `sanity.cli.ts`. For later deploys, omit `--create` and run `npm run deploy -- --yes --json`. `--create` rejects a config that already has an app ID.
Agent terminals may disable interactive prompts even with a PTY (`TERM=dumb`). Use these flags rather than changing terminal settings or calling the applications API directly. Run `npm run deploy -- --help` to check which flags the installed CLI supports.
This app is not a Studio. For a Studio's first hosted deployment, use `sanity deploy --url <hostname> --yes`; `studioHost`, if used in config, belongs at the top level, not inside `deployment`.
## Working with the App SDK
If the Sanity MCP server is available, call its `get_sanity_rules` tool with the `app-sdk` rule before writing SDK code. That rule is the maintained guide and supersedes t
[... 1286 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity && ls | grep -i workflow; cat workflow-sdk/package.json | head -60; ls workflow-sdk/dist
workflow-components
workflow-diagram
workflow-engine
workflow-react
workflow-sdk
workflow-studio
workflow-studio-plugin
{
"name": "@sanity/workflow-sdk",
"version": "0.35.0",
"description": "React adapter that drives the @sanity/workflow-engine reactive session from the Sanity App SDK document store.",
"keywords": [
"sanity",
"sanity-io",
"sdk",
"workflow",
"workflows"
],
"homepage": "https://github.com/sanity-io/workflows/tree/main/packages/workflow-sdk#readme",
"bugs": {
"url": "https://github.com/sanity-io/workflows/issues"
},
"license": "MIT",
"author": "Sanity.io <hello@sanity.io>",
"repository": {
"type": "git",
"url": "git+https://github.com/sanity-io/workflows.git",
"directory": "packages/workflow-sdk"
},
"files": [
"dist",
"CHANGELOG.md"
],
"type": "module",
"sideEffects": false,
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
},
"./observer": {
"import": "./dist/observer.js",
"require": "./dist/observer.cjs",
"default": "./dist/observer.js"
},
"./project-users": {
"import": "./dist/project-users.js",
"require": "./dist/project-users.cjs",
"default": "./dist/project-users.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@sanity/telemetry": "^1.1.0",
"dataloader": "^2.2.3"
},
"devDependencies": {
"@sanity/pkg-utils": "^10.5.2",
"@sanity/sdk": "^3.3.0",
"@sanity/sdk-react": "^3.3.0",
index.cjs
index.d.cts
index.d.ts
index.js
observer.cjs
observer.d.cts
observer.d.ts
observer.js
project-users.cjs
project-users.d.cts
project-users.d.ts
project-users.js
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity/workflow-sdk && cat README.md | head -200; wc -l dist/index.d.ts
# @sanity/workflow-sdk
Reactive workflow adapter over the **App SDK** store. `useWorkflowSession`
drives an `@sanity/workflow-engine` session from `getDocumentState`
(optimistic, per-doc) and returns
`{evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}`.
`useWorkflowInstances` / `useDocumentWorkflows` are live instance lists
(filterable collection / per-document discovery) read from the engine's state
dataset via `getQueryState`, each returning `{instances, loading, unreadable, error}` —
rows the read discipline refuses are skipped from `instances` and flagged in
`unreadable`, so one bad row can't sink the listing.
The session's `invalid` is the explicit validation state (a model-ahead doc
needs a newer `@sanity/workflow-engine`; a shape violation is a corrupt
stored tree) — surfaced as state instead of a throw, never `ready` while set.
SDK state-source throws are normalized into the same reactive observer state;
`error` clears when a later live emission succeeds.
```tsx
const engine = useMemo(() => createEngine({client, workflowResource, tag}), [client])
const {evaluation, ready, guards, fireAction} = useWorkflowSession({engine, instanceId})
const {instances, loading} = useDocumentWorkflows({engine, document: subjectGdrUri})
```
## Installation
Workflows SDK and Studio integrations require `@sanity/sdk` 3.1 or later in
the 3.x line. The React entry of `@sanity/workflow-sdk` also requires the
matching `@sanity/sdk-react` version.
SDK 3.1.0 allows `@sanity/mutate` 0.18.1, which can leave document reads
pending with Sanity client 8. Before installing or upgrading Workflows,
configure your application to select Mutate 0.18.2 for SDK 3. An existing
lockfile can otherwise retain 0.18.1 even after upgrading the SDK.
For npm, merge this fragment into your application's root `package.json`:
```json
{
"overrides": {
"@sanity/sdk": {
"@sanity/mutate": "0.18.2"
}
}
}
```
For pnpm, merge this fragment into the root `pnpm-workspace.yaml`:
```yaml
overrides:
'@sanity/sdk@3>@sanity/mutate': 0.18.2
```
Run your package manager's install command to update the lockfile, then
commit both configuration and lockfile. With npm, inspect the installed
dependency tree:
```sh
npm install
npm ls @sanity/sdk @sanity/mutate
```
For pnpm, run `pnpm install` and `pnpm why @sanity/mutate`.
Verify that SDK 3 resolves Mutate 0.18.2. Other dependency branches can use
their own Mutate versi
[... 10657 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity && cat workflow-sdk/dist/index.d.ts; grep -n "createEngine\|export declare function\|workflowResource" workflow-engine/dist/index.d.ts | head -40; grep -rn "createEngine\|workflowResource" /d/Repos/web-apps/estian-tattler/studio/sanity.config.ts /d/Repos/web-apps/estian-tattler/newsroom/*.ts 2>/dev/null | head
import { Engine } from "@sanity/workflow-engine";
import { EngineEffectsArgs } from "@sanity/workflow-engine";
import type { GdrUri } from "@sanity/workflow-engine";
import type { InstancesQueryFilter } from "@sanity/workflow-engine";
import { MutationGuardDoc } from "@sanity/workflow-react";
import { ProjectMembersState } from "@sanity/workflow-components";
import { ProjectUserDirectory } from "@sanity/workflow-engine";
import { ReactElement } from "react";
import { ReactNode } from "react";
import { ResourceClientResolver } from "@sanity/workflow-engine";
import { SanityInstance } from "@sanity/sdk";
import { SanityUser } from "@sanity/sdk";
import { useClient } from "@sanity/sdk-react";
import { useWorkflowTelemetry } from "@sanity/workflow-react";
import { WorkflowInstanceList } from "@sanity/workflow-react";
import { WorkflowResource } from "@sanity/workflow-engine";
import { WorkflowSession } from "@sanity/workflow-react";
export { MutationGuardDoc };
export { SanityUser };
declare type SdkClient = ReturnType<typeof useClient>;
/** App SDK project-user integration for the engine's actor-resolution contract. */
export declare function sdkProjectUserDirectory(
sdk: SanityInstance,
projectId: string,
): ProjectUserDirectory<SanityUser>;
/** Which in-flight instances reference this document (GDR URI) — live,
* observed through the App SDK. */
export declare function useDocumentWorkflows({
engine,
document,
}: {
engine: Engine;
document: GdrUri;
}): WorkflowInstanceList;
/**
* Load human members, assignable roles, and loading/error state for a project.
* Resolved profiles supply account-global user IDs. A member without that ID
* retains the project membership ID. New assignments reject unresolved project IDs.
*
* Role reads use {@link loadProjectRolesOrNone}. If the catalog is unavailable,
* member loading still succeeds and assignment controls use member-held roles.
*/
export declare function useProjectMembers(
projectId: string,
): ProjectMembersState;
/**
* Build the App SDK-side engine once: the app client rebound to the engine's
* state dataset, declaring this host so its events and history entries are
* attributable to an App SDK app rather than an unlabelled browser.
* `resourceClients` has no default — pass one to resolve subjects and refs
* that live in another dataset. `effects` feeds {@link Engine.drainEffects}
* for engines that drain effects in the app. Memoized on the config's
* content,
[... 5702 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler && cat newsroom/workflows/story-desk.ts; ls studio studio/*; cat studio/actions/*itch* 2>/dev/null || grep -rln "startInstance\|Pitch" studio --include=*.ts* --exclude-dir=node_modules
import {
defineAction,
defineActivity,
defineField,
defineStage,
defineTransition,
defineWorkflow,
} from '@sanity/workflow-engine/define'
export const MAX_DRAFTS = 3
const drafted = "$effectStatus['draft-story'] == 'done'"
const checked = "$allActivitiesDone && $effectStatus['fact-check'] == 'done'"
/** A story's trip through the Tattler: reported by the model, checked by code, printed by a person. */
export const storyDesk = defineWorkflow({
name: 'story-desk',
title: 'Story desk',
description: 'Reporting, fact-check against the save, the editor, then the press.',
initialStage: 'reporting',
fields: [
defineField({type: 'subject', name: 'subject', title: 'Story', initialValue: {type: 'input'}, required: true}),
defineField({type: 'number', name: 'drafts', title: 'Drafts filed', initialValue: {type: 'literal', value: 0}}),
defineField({type: 'text', name: 'deskNote', title: 'Note for the reporter'}),
defineField({type: 'progress', name: 'reporting', title: 'Reporting'}),
],
stages: [
defineStage({
name: 'reporting',
title: 'Reporting',
activities: [
defineActivity({
name: 'report',
title: 'Write it from the records',
actions: [
defineAction({
name: 'assign',
title: 'Send a reporter',
when: 'true',
ops: [{type: 'field.inc', target: {field: 'drafts', scope: 'workflow'}}],
effects: [
{name: 'draft-story', bindings: {subject: '$fields.subject._id', note: '$fields.deskNote'}},
],
}),
defineAction({name: 'filed', title: 'Draft filed', when: drafted, status: 'done'}),
defineAction({
name: 'lost',
title: 'Reporter never filed',
when: "$effectStatus['draft-story'] == 'failed'",
status: 'failed',
}),
],
}),
],
transitions: [defineTransition({name: 'to-fact-check', to: 'fact-check'})],
}),
defineStage({
name: 'fact-check',
title: 'Fact-check',
activities: [
defineActivity({
name: 'check',
title: 'Check every sentence against its receipts',
actions: [
defineAction({
name: 'run',
title: 'Run the fact-check',
when: 'true',
effects: [
{
name: 'fact-check',
[... 12279 more characters trimmed]
Building the Night Desk App SDK app now: reading the Pitch action and the workflow-react types to wire the board and actions.
$ cat studio/components/PitchAction.tsx; cat studio/schemaTypes/story.ts | head -60
import {useState} from 'react'
import {ComposeIcon} from '@sanity/icons/Compose'
import {useClient, type DocumentActionComponent} from 'sanity'
import {useRouter} from 'sanity/router'
/** "Pitch a story" on any record: opens a new story with the record as its first lead. */
export const PitchAction: DocumentActionComponent = ({id, published}) => {
const client = useClient({apiVersion: '2025-02-19'})
const router = useRouter()
const [busy, setBusy] = useState(false)
const record = published as {label?: string; text?: string} | null
return {
label: 'Pitch a story',
icon: ComposeIcon,
disabled: busy || !record,
onHandle: async () => {
setBusy(true)
const storyId = `story-${Math.random().toString(36).slice(2, 10)}`
const seed = (record?.label || record?.text || '').replace(/\s+/g, ' ')
await client.create({
_id: `drafts.${storyId}`,
_type: 'story',
headline: seed.length > 90 ? `${seed.slice(0, 87)}...` : seed,
leads: [{_key: id.replace(/\W/g, ''), _type: 'reference', _ref: id}],
})
router.navigateIntent('edit', {id: storyId, type: 'story'})
},
}
}
import {defineArrayMember, defineField, defineType} from 'sanity'
import {ClaimAnnotation, AsideAnnotation} from '../components/annotations'
import {checkWithMorgue} from '../lib/morgue'
import type {StoryInput} from '../lib/factcheck'
export const SECTIONS = ['Front page', 'Hearts', 'Science', 'Brawls', 'Commerce', 'Weather', 'Graves']
export const story = defineType({
name: 'story',
title: 'Story',
type: 'document',
// The desk won't publish a story the fact-checker would reject, whoever edited it last.
validation: (rule) =>
rule.custom(async (doc, context) => {
if (!doc) return true
const {verdict} = await checkWithMorgue(context.getClient({apiVersion: '2025-02-19'}), doc as StoryInput)
if (verdict.passed) return true
return verdict.problems.slice(0, 3).map((p) => `"${p.text}" ${p.reason}`).join('\n')
}),
fields: [
defineField({name: 'headline', type: 'string', validation: (rule) => rule.required().max(90)}),
defineField({name: 'dek', type: 'string', description: 'The line under the headline.'}),
defineField({name: 'section', type: 'string', options: {list: SECTIONS}}),
defineField({
name: 'pitch',
type: 'text',
rows: 2,
description: 'What the desk wants from the reporter.',
}),
defineField({
[... 1021 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity && grep -n "WorkflowInstanceList\b\|interface WorkflowInstanceList\|type WorkflowInstanceList" -A25 workflow-react/dist/index.d.ts | head -60; grep -n "interface WorkflowSession\|type WorkflowSession " -A60 workflow-react/dist/index.d.ts | head -100
224:}): WorkflowInstanceList;
225-
226-export declare function useInstancePreviews({
227- engine,
228- observer,
229- filter,
230- pageSize,
231- interPageDelayMs,
232-}: {
233- engine: Engine;
234- observer: WorkflowObserver;
235- filter?: InstancePreviewsFilter;
236- /** Rows per hydration page. Small pages surface progressively; the default
237- * drains a few thousand runs in a handful of fetches. */
238- pageSize?: number;
239- /** A pause between hydration pages — a development throttle for watching
240- * progressive hydration; leave unset in production. */
241- interPageDelayMs?: number;
242-}): InstancePreviewList;
243-
244-/**
245- * A refcounted id set: acquire/release per mounted consumer, the list of live
246- * ids as state. An id stays live while ANY consumer holds it, so several
247- * surfaces can independently keep the same id registered without racing each
248- * other's teardown — the store-agnostic building block for "a document is
249- * watched while any surface reads it" registration. `releaseDelayMs` keeps an
--
280:}): WorkflowInstanceList;
281-
282-/**
283- * Drive a workflow instance reactively from any document store. Watches the
284- * instance and its {@link SubscriptionDocument} set through the supplied
285- * {@link WorkflowObserver}, feeds their optimistic values into the engine's
286- * session ready-gated, and re-evaluates as they change. The session never
287- * advances on its own — the consumer calls `tick`/`fireAction`, which commit
288- * through the engine's own (lake-guarded) client, not the store. A settled
289- * commit re-evaluates immediately — surfaces repaint without waiting for the
290- * store to echo the write.
291- */
292-export declare function useWorkflowSession({
293- engine,
294- instanceId,
295- observer,
296- host,
297- guardScope,
298- opts,
299-}: {
300- engine: Engine;
301- instanceId: string;
302- observer: WorkflowObserver;
303- /** The adapter package hosting this session — rides the
304- * {@link WorkflowSessionStarted} adoption event, and (when set) is pushed
305- * once per host store as the `surface` user property so the host's
--
333:export declare interface WorkflowInstanceList {
334- instances: readonly WorkflowInstance[] | undefined;
335- /** True only while the observer snapshot is pending. An empty `instances`
336- * with `loading: false` and no failure is a confirmed "none". */
337- loading: boolean;
338- /** Rows the read discipline refu
[... 4392 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/studio/node_modules/@sanity/workflow-engine/dist && grep -n "interface WorkflowInstance \|type WorkflowInstance =" -A50 index.d.ts | head -80; grep -n "startInstance\|InstancesQueryFilter\b" index.d.ts | head; grep -n "interface WorkflowEvaluation\b\|type WorkflowEvaluation =" -A40 index.d.ts | head -60
10302:export declare interface WorkflowInstance extends SanityDocument {
10303- _type: typeof WORKFLOW_INSTANCE_TYPE;
10304- /**
10305- * Engine data-model stamp — the shape contract this document conforms to
10306- * (see {@link DATA_MODEL_VERSION}), orthogonal to the definition-content
10307- * pins (`pinnedVersion` / `pinnedContentHash`). Stamped at create and
10308- * re-asserted on every full persist; absent on documents last written
10309- * before the stamp existed (model 0).
10310- */
10311- modelVersion?: number;
10312- /**
10313- * Reader floor — the oldest engine data model that can safely interpret
10314- * this document. Derived from its persisted features and the retained
10315- * model-4 baseline, and written alongside
10316- * {@link WorkflowInstance.modelVersion}. Full persists never lower it.
10317- */
10318- minReaderModel?: number;
10319- /**
10320- * Engine-scope environment partition stamped on the instance at create
10321- * time. Reads are scoped to a single tag, so an engine only sees
10322- * instances whose `tag` equals its own.
10323- */
10324- tag: string;
10325- /**
10326- * The Sanity resource this instance lives in. Stored on the doc so
10327- * any internal operation can mint GDRs for ancestors / spawned
10328- * children / etc. without re-supplying it. Mirrors
10329- * `@sanity/client`'s `ClientConfigResource`.
10330- */
10331- workflowResource: WorkflowResource;
10332- /** Reference to the deployed definition, by its `name`. */
10333- definition: string;
10334- pinnedVersion: number;
10335- /**
10336- * Content fingerprint of the pinned definition version (see
10337- * {@link hashDefinitionContent}). Pinned alongside the version so a consumer
10338- * can detect a deployed definition that drifted from what this instance
10339- * started on. Advisory — the engine enforces nothing; this enables detection,
10340- * not prevention. Absent when the instance was started against a definition
10341- * deployed before content-addressing (it had no hash to pin).
10342- */
10343- pinnedContentHash?: string;
10344- /** Frozen JSON snapshot of the definition at the moment the instance started. */
10345- definitionSnapshot: string;
10346- /**
10347- * Resolved workflow fields, retained for the instance's lifetime. Their
10348- * initial values come from the definition's `fields` declarations and
10349- * {@link StartInstanceArgs.initialFields}.
10350- *
[... 3252 more characters trimmed]
$ sed -n 10352,10420p index.d.ts; grep -n "interface StartInstanceArgs" -A40 index.d.ts | head -50; grep -n "interface InstancesQueryFilter\|type InstancesQueryFilter" -A30 index.d.ts | head -40; grep -n "interface StageEvaluation" -A25 index.d.ts; grep -n "interface ActivityEvaluation" -A25 index.d.ts; grep -n "type OperationResult\|interface OperationResult" -A15 index.d.ts
* `{type: 'subject', name: 'subject', initialValue: {type: 'input'}}`.
* Conditions read that entry as `$fields.subject`. The `subject` field
* type identifies its role; naming a `doc.ref` field `subject` does not.
*/
fields: ResolvedFieldEntry[];
/**
* The start seed: named values set once at `startInstance` (or by a
* parent's `spawn.context`) and never mutated after. Conditions and
* effect bindings read them as `$context.<name>`.
*/
context: ContextEntry[];
/**
* Chain of ancestor workflow instances, root-first. Each entry is a
* GDR pointing at a {@link WORKFLOW_INSTANCE_TYPE} document in the
* engine's own workflow resource.
*/
ancestors: GlobalDocumentReference[];
/**
* Perspective for content reads, including field queries and subworkflow
* discovery. Defaults to {@link DEFAULT_CONTENT_PERSPECTIVE}, where drafts
* take precedence over published content. Set it through
* {@link StartInstanceArgs.perspective}; child instances inherit it.
*
* Engine-owned instance and definition documents, and `system.release`
* documents, always read under `raw`.
*/
perspective?: WorkflowPerspective;
currentStage: StageName;
/**
* Per-stage instance entries — one StageEntry per stage the instance
* has been in, in entry order. Past stages persist with `exitedAt`
* set; the current stage is the entry whose `exitedAt` is undefined.
* Each entry owns its activities.
*/
stages: StageEntry[];
/**
* Workflow-scope registry of every child this instance ever spawned;
* see {@link SubworkflowEntry}. Rows are never deleted; a row without
* `resolved` is a live child (watched, hydrated, propagating), one with
* it is terminal, and a live row with `abortPending` is condemned — the
* cascade owes it an abort. Rendered as the `$subworkflows` condition
* var. Absent only on instances persisted before the registry existed.
*/
subworkflows?: SubworkflowEntry[];
pendingEffects: PendingEffect[];
effectHistory: EffectHistoryEntry[];
history: HistoryEntry[];
/**
* Idempotency ledger — see {@link ProcessedRequest}. Optional because
* instances persisted before the ledger existed lack it (any commit
* materialises it, empty). Only keyed operations add rows — it never
* grows on instances whose callers pass no `idempotencyKey`.
*/
processedRequests?: ProcessedRequest[];
startedAt: string;
lastChangedAt: string;
completedAt?: string;
/*
[... 7684 more characters trimmed]
$ sed -n 5848,5900p index.d.ts; grep -n "interface ActionEvaluation" -A30 index.d.ts | head -40
/** Current viewer assignment filter. `unrouted` needs no identity match;
* `routed` matches literal role members only while no user holds the
* activity; `held` matches the direct user and shadows every role. */
assignment?: {
userId: string;
roles?: readonly string[] | undefined;
states?: readonly AssignmentState[] | undefined;
};
/** Include completed/aborted instances (default: in-flight only). */
includeCompleted?: boolean;
/**
* Cap the read to the newest `limit` instances — the query flips to
* `startedAt desc` and slices, so a bounded consumer (a dashboard over an
* unbounded dataset) reads the most recent rows instead of the oldest.
* Unlimited reads keep the ascending order adapters index by. Must be a
* positive integer.
*/
limit?: number;
/**
* Keyset cursor into the newest-first order: only instances strictly older
* than this position. Pass the last row of the previous page, and the next
* `limit` rows continue where it ended. A cursor rather than an offset, so
* rows starting or concluding between pages can't shift what a page holds.
* Requires `limit`: pages only exist in the newest-first sliced read.
*/
before?: {
/** The `_id` tiebreak for rows sharing `startedAt`. */
id: string;
startedAt: string;
};
}
/**
* Whether {@link document} is in {@link instance}'s reactive watch-set — the
* reverse of {@link subscriptionDocumentsForInstance}. Both derive from
* the shared watch-ref collector, the single source of truth, so "which docs does
* this instance watch" and "which instances watch this doc" stay in lockstep
* — the same way `hydrateSnapshot`'s load-set does. Matching is on the
* resource-qualified GDR URI, so a cross-dataset subject (`dataset:A:ds:doc`)
* never matches a same-id doc in another resource (`dataset:B:ds:doc`).
*
* A non-reactive, content-change-driven runtime uses this to decide whether a
* changed document should re-`tick` an instance it does not hold in memory.
*/
export declare function instanceWatchesDocument(
instance: WorkflowInstance,
document: GdrUri,
): boolean;
/** The canonical cascade-fired test: structural on `when`, so both authored
* and stored shapes qualify. */
export declare function isCascadeFired(action: {
when?: string | undefined;
156:export declare interface ActionEvaluation {
157- action: Action;
158- /** The action's advisory workflow meaning, unchanged from its definition. */
159- sem
[... 1577 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && cat package.json sanity.cli.ts src/App.tsx; ls src; cat src/*.css 2>/dev/null | head -30; cd ../studio && node -e "const p=require('./package.json');console.log(p.dependencies,p.overrides)"
{
"name": "nightdesk",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "package.json",
"license": "UNLICENSED",
"scripts": {
"build": "sanity build",
"deploy": "sanity deploy",
"dev": "sanity dev",
"start": "sanity start"
},
"keywords": [
"sanity"
],
"dependencies": {
"@sanity/sdk": "^2",
"@sanity/sdk-react": "^2",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@sanity/eslint-config-studio": "^7",
"@types/react": "^19.2.14",
"eslint": "^10.8.1",
"prettier": "^3.5",
"sanity": "^6.16.0",
"typescript": "^5.8"
}
}
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'oj47h4o89',
entry: './src/App.tsx',
},
})
import {type SanityConfig} from '@sanity/sdk'
import {SanityApp} from '@sanity/sdk-react'
import {ExampleComponent} from './ExampleComponent'
import './App.css'
function App() {
// apps can access many different projects or other sources of data
const sanityConfigs: SanityConfig[] = [
{
projectId: '',
dataset: '',
},
]
return (
<div className="app-container">
<SanityApp config={sanityConfigs} fallback={<div>Loading...</div>}>
{/* add your own components here! */}
<ExampleComponent />
</SanityApp>
</div>
)
}
export default App
App.css
App.tsx
ExampleComponent.css
ExampleComponent.tsx
/* Container styling for the app */
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
}
/* Basic reset */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background-color: #f9f9f9;
}
.example-container {
background-color: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.example-avatar-container {
position: relative;
margin-block-end: 1rem;
{
'@sanity/icons': '^5.2.2',
'@sanity/sdk': '^3.5.0',
'@sanity/ui': '^4.2.4',
'@sanity/vision': '^6.16.0',
'@sanity/workflow-components': '^0.35.0',
'@sanity/workflow-diagram': '^0.35.0',
'@sanity/workflow-engine': '^0.35.0',
'@sanity/workflow-react': '^0.35.0',
'@sanity/workflow-sdk': '^0.35.0',
'@sanity/workflow-studio': '^0.35.0',
'@sanity/workflow-studio-plugin': '^0.35.0',
react: '^19.3.0',
'react-dom': '^19
[... 113 more characters trimmed]
Setting up the Night Desk app's dependencies (SDK 3.x plus the workflow packages).
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && rm -rf .claude AGENTS.md src/ExampleComponent.* && node -e "
const fs=require('fs');const p=require('./package.json');
p.dependencies={'@sanity/sdk':'^3.5.0','@sanity/sdk-react':'^3.5.0','@sanity/workflow-components':'^0.35.0','@sanity/workflow-engine':'^0.35.0','@sanity/workflow-react':'^0.35.0','@sanity/workflow-sdk':'^0.35.0',react:'^19.3.0','react-dom':'^19.3.0'};
p.overrides={'@sanity/sdk':{'@sanity/mutate':'0.18.2'}};
delete p.main; p.license='MIT';
fs.writeFileSync('package.json',JSON.stringify(p,null,2)+'\n')" && npm install 2>&1 | tail -15 && npm ls @sanity/sdk @sanity/mutate 2>&1 | head -20
added 26 packages, removed 8 packages, changed 5 packages, and audited 1057 packages in 13s
202 packages are looking for funding
run `npm fund` for details
13 vulnerabilities (10 moderate, 3 high)
To address all issues (including breaking changes), run:
npm audit fix --force
Run `npm audit` for details.
nightdesk@1.0.0 D:\Repos\web-apps\estian-tattler\nightdesk
+-- @sanity/sdk-react@3.5.0
| `-- @sanity/sdk@3.5.0 deduped
+-- @sanity/sdk@3.5.0 overridden
| `-- @sanity/mutate@0.18.2
+-- @sanity/workflow-sdk@0.35.0
| `-- @sanity/sdk@3.5.0 deduped
`-- sanity@6.16.0
+-- @sanity/migrate@8.0.5
| `-- @sanity/mutate@0.18.2 deduped
+-- @sanity/mutate@0.18.2 deduped
`-- @sanity/sdk@3.5.0 deduped
$ cd /d/Repos/web-apps/estian-tattler && cat studio/schemaTypes/record.ts studio/schemaTypes/pawn.ts | head -80; grep -n "export declare function useQuery\|export declare function useClient\|export declare function useApplyDocumentActions\|export declare function createDocument\|export declare const useQuery\|export declare const useClient\|useApplyDocumentActions:" nightdesk/node_modules/@sanity/sdk-react/dist/index.d.ts nightdesk/node_modules/@sanity/sdk/dist/index.d.ts | head
import {defineArrayMember, defineField, defineType} from 'sanity'
// One thing the save file can prove happened. Written by ingest/parse_save.py, never by hand.
export const record = defineType({
name: 'record',
title: 'Record',
type: 'document',
readOnly: true,
fields: [
defineField({
name: 'kind',
type: 'string',
options: {list: ['tale', 'letter', 'message', 'talk']},
description: 'Tales are what pawns remember, letters and messages are what the game told the player, talks come from the play log.',
}),
defineField({name: 'text', type: 'text', rows: 3}),
defineField({name: 'label', type: 'string'}),
defineField({name: 'colonyDay', type: 'number'}),
defineField({name: 'hour', type: 'number'}),
defineField({name: 'pawns', type: 'array', of: [defineArrayMember({type: 'reference', to: [{type: 'pawn'}]})]}),
defineField({name: 'def', type: 'string', description: "The game's def for the tale, letter or interaction."}),
defineField({name: 'subjectDef', type: 'string'}),
defineField({name: 'subjectLabel', type: 'string'}),
defineField({name: 'letterClass', type: 'string'}),
defineField({name: 'tick', type: 'number'}),
defineField({name: 'sourceId', type: 'string', description: 'ID of the entry inside the save.'}),
],
orderings: [{title: 'Colony day', name: 'day', by: [{field: 'tick', direction: 'asc'}]}],
preview: {
select: {text: 'text', label: 'label', day: 'colonyDay', kind: 'kind'},
prepare: ({text, label, day, kind}) => ({title: label || text, subtitle: `Day ${day} · ${kind}`}),
},
})
import {defineArrayMember, defineField, defineType} from 'sanity'
export const pawn = defineType({
name: 'pawn',
title: 'Pawn',
type: 'document',
readOnly: true,
fields: [
defineField({name: 'name', type: 'string', description: 'As of the last record that mentions them.'}),
defineField({name: 'shortName', type: 'string'}),
defineField({
name: 'formerNames',
type: 'array',
of: [defineArrayMember({type: 'string'})],
description: 'Other names the save has on file, usually from a marriage.',
}),
defineField({name: 'aliases', type: 'array', of: [defineArrayMember({type: 'string'})]}),
defineField({name: 'gender', type: 'string'}),
defineField({name: 'age', type: 'number'}),
defineField({name: 'relationNote', type: 'string'}),
defineField({name: 'everColonist', type: 'boolean'}),
defineField({name: 'thingId',
[... 938 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/nightdesk/node_modules/@sanity && sed -n 3380,3440p sdk-react/dist/index.d.ts; grep -n "export declare function createDocument" -A12 sdk/dist/index.d.ts | head -40; grep -n "UseApplyDocumentActions\b" -A20 sdk-react/dist/index.d.ts | head -40; grep -n "count(\*\[_type==\"pawn\"\]\|everColonist" /dev/null; python /c/tmp/q.py 'count(*[_type=="pawn" && everColonist])'
* })
*
* return (
* // utilize `isPending` to signal to users that new data is coming in
* // (e.g. the `movieId` changed and we're loading in the new one)
* <div style={{ opacity: isPending ? 0.5 : 1 }}>
* {data ? <h1>{data.title}</h1> : <p>Movie not found</p>}
* </div>
* )
* }
* ```
*/
export declare function useQuery<TQuery extends string = string, TDataset extends string = string, TProjectId extends string = string>(options: UseQueryOptions<TQuery, TDataset, TProjectId>): {
/** The query result, typed based on the GROQ query string */
data: ResolveQueryResult<TQuery, `${TProjectId}.${TDataset}`>;
/** True if a query transition is in progress */
isPending: boolean;
};
/**
* @public
* Executes a GROQ query with an explicitly provided result type `TData`.
*
* @param options - Configuration for the query, including `query`, optional `params`, `projectId`, `dataset`, etc.
* @returns An object containing `data` (cast to `TData`) and `isPending` (indicates whether a query resolution is pending; note that Suspense handles initial loading states). *
* @example Manually typed query result
* ```tsx
* import {useQuery} from '@sanity/sdk-react'
*
* interface CustomMovieTitle {
* movieTitle?: string
* }
*
* function FirstMovieTitle() {
* // Provide the explicit type TData
* const {data, isPending} = useQuery<CustomMovieTitle>({
* query: '*[_type == "movie"][0]{ "movieTitle": title }'
* })
*
* return (
* <h1 style={{ opacity: isPending ? 0.5 : 1 }}>
* {data?.movieTitle ?? 'No title found'}
* </h1>
* )
* }
* ```
*/
export declare function useQuery<TData>(options: WithResourceNameSupport<QueryOptions>): {
/** The query result, cast to the provided type TData */
data: TData;
/** True if another query is resolving in the background (suspense handles the initial loading state) */
isPending: boolean;
};
/**
* @public
* @function
*
* Returns the active releases for the current project,
* represented as a list of release documents.
*
* @returns The active releases for the current project.
* @category Releases
1844:interface UseApplyDocumentActions {
1845- (): <TDocumentType extends string = string, TDataset extends string = string, TProjectId extends string = string>(action: DocumentAction<TDocumentType, TDataset, TProjectId> | DocumentAction<TDocumentType, TDataset, TProjectId>[], options?: ResourceHandle) => Promise<ActionsResult<ResolveDocumen
[... 1782 more characters trimmed]
$ sed -n 2043,2110p sdk-react/dist/index.d.ts; python /c/tmp/q.py '*[_type=="pawn" && everColonist]{_id,name,"n":count(*[_type=="record" && references(^._id)])}'
/**
* @public
* Create a new document, relying on Typegen for the initial-value type.
*
* @param options - A document-type handle including `documentType`, an optional `documentId`, and optionally `projectId`/`dataset`/`perspective`.
* @returns A function that creates the document. It accepts optional initial field values and an optional `{documentId}` override,
* and resolves to the {@link DocumentHandle} of the created document (carrying the generated or supplied id).
*/
export declare function useCreateDocument<TDocumentType extends string = string, TDataset extends string = string, TProjectId extends string = string>(options: DocumentTypeHandle<TDocumentType, TDataset, TProjectId>): (initialValue?: Partial<Omit<ResolveDocument<TDocumentType, `${TProjectId}.${TDataset}`>, IgnoredKey>>, overrides?: CreateDocumentOverrides) => Promise<DocumentHandle<TDocumentType, TDataset, TProjectId>>;
/**
* @public
* Create a new document with an explicit type `TData`.
*
* @param options - A document-type handle including `documentType` and optionally `projectId`/`dataset`/`perspective`.
* @returns A function that creates the document. It accepts optional initial field values (typed against `TData`) and an
* optional `{documentId}` override, and resolves to the {@link DocumentHandle} of the created document.
*/
export declare function useCreateDocument<TData extends Record<string, unknown>>(options: DocumentTypeHandle): (initialValue?: Partial<Omit<TData, IgnoredKey>>, overrides?: CreateDocumentOverrides) => Promise<DocumentHandle>;
type UseDocumentOptions<TPath extends string | undefined = undefined, TDocumentType extends string = string, TDataset extends string = string, TProjectId extends string = string> = DocumentHandle<TDocumentType, TDataset, TProjectId> & {
path?: TPath;
};
interface UseDocument {
/** @internal */
<TDocumentType extends string, TDataset extends string, TProjectId extends string = string>(options: UseDocumentOptions<undefined, TDocumentType, TDataset, TProjectId>): {
data: ResolveDocument<TDocumentType, `${TProjectId}.${TDataset}`> | null;
};
/** @internal */
<TPath extends string, TDocumentType extends string, TDataset extends string = string, TProjectId extends string = string>(options: UseDocumentOptions<TPath, TDocumentType>): {
data: JsonMatch<ResolveDocument<TDocumentType, `${TProjectId}.${TDataset}`>, TPath> | undefined;
};
/** @internal */
<TData>(options: DocumentOptio
[... 1853 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'});
c.fetch('*[_id match \"*a37f315f7d77*\"][0]').then(d=>{console.log(d._id,d._type,d.currentStage);console.log(JSON.stringify(d.fields,null,1).slice(0,1500));console.log(JSON.stringify(d.history.slice(-3),null,1).slice(0,1500))})" 2>&1 | head -120
production.wf-instance.a37f315f7d77 sanity.workflow.instance printed
[
{
"_key": "54893fc18061",
"_type": "subject",
"name": "subject",
"title": "Story",
"value": {
"id": "dataset:lcvgtfvq:production:story-restaurant-reviews",
"type": "story"
}
},
{
"_key": "7092ca25d3b6",
"_type": "number",
"name": "drafts",
"title": "Drafts filed",
"value": 1
},
{
"_key": "40baf99a8e4e",
"_type": "text",
"name": "deskNote",
"title": "Note for the reporter",
"value": null
},
{
"_key": "32e34743bd41",
"_type": "progress",
"name": "reporting",
"title": "Reporting",
"value": 100
}
]
[
{
"_key": "ca269c5039cc",
"_type": "stageExited",
"actor": {
"id": "g-GhSvcjjlfvuL",
"kind": "person",
"roles": [
"editor"
]
},
"at": "2026-09-25T14:02:24.752Z",
"executionContext": {
"id": "estian-newsroom",
"kind": "server",
"runtime": "node"
},
"stage": "printing",
"toStage": "printed",
"transition": "to-printed"
},
{
"_key": "d89171667275",
"_type": "transitionFired",
"actor": {
"id": "g-GhSvcjjlfvuL",
"kind": "person",
"roles": [
"editor"
]
},
"at": "2026-09-25T14:02:24.752Z",
"executionContext": {
"id": "estian-newsroom",
"kind": "server",
"runtime": "node"
},
"fromStage": "printing",
"toStage": "printed",
"transition": "to-printed"
},
{
"_key": "2d790efd6ba3",
"_type": "stageEntered",
"actor": {
"id": "g-GhSvcjjlfvuL",
"kind": "person",
"roles": [
"editor"
]
},
"at": "2026-09-25T14:02:24.752Z",
"executionContext": {
"id": "estian-newsroom",
"kind": "server",
"runtime": "node"
},
"fromStage": "printing",
"stage": "printed",
"transition": "to-printed"
}
]
$ python /c/tmp/q.py '{"cited": count(array::unique(*[_type=="story" && defined(printedAt)].body[].markDefs[_type=="claim"].records[]._ref)), "total": count(*[_type=="record"]), "pawns": *[_type=="pawn"]{name, everColonist, "n": count(*[_type=="record" && references(^._id)])} | order(n desc)[0..5], "desk": *[_type=="story" && !defined(printedAt)]{_id, headline}}'
{
"cited": 100,
"desk": [
],
"pawns": [
{
"everColonist": true,
"n": 136,
"name": "Reiraborvas 'Grasshopper' Canga"
},
{
"everColonist": true,
"n": 114,
"name": "Hyena Embaixada"
},
{
"everColonist": true,
"n": 113,
"name": "Snake Rato"
},
{
"everColonist": true,
"n": 91,
"name": "Rocaniraalbo Ambmea"
},
{
"everColonist": null,
"n": 30,
"name": "Charites Decrescendo"
},
{
"everColonist": null,
"n": 29,
"name": "Marcellina Triarius"
}
],
"total": 631
}
Writing the Night Desk components now (board, run panel with editor actions, coverage meter with pitching).
D:\Repos\web-apps\estian-tattler\nightdesk\src\desk.ts
File created successfully at: D:\Repos\web-apps\estian-tattler\nightdesk\src\desk.ts (file state is current in your context — no need to Read it back)
$ ls; grep -n "pitch\|leads\|headline\|section" effects/*.ts reporter*.ts 2>/dev/null | head -30; grep -rn "actions:" /d/Repos/web-apps/estian-tattler/nightdesk/node_modules/@sanity/workflow-engine/dist/index.d.ts | sed -n 1,5p; sed -n 543,580p /d/Repos/web-apps/estian-tattler/nightdesk/node_modules/@sanity/workflow-engine/dist/index.d.ts
desk-runner.ts
effects
lib
node_modules
package-lock.json
package.json
sanity.workflow.ts
test
tsconfig.json
workflows
effects/draft-story.ts:11:type Filed = {headline: string; dek: string; paragraphs: Sentence[][]}
effects/draft-story.ts:15: required: ['headline', 'dek', 'paragraphs'],
effects/draft-story.ts:18: headline: {type: 'string', maxLength: 90},
effects/draft-story.ts:48:- The headline and dek follow the claim rules, checked against every record the body cites.
effects/draft-story.ts:74:/** Sends a Claude reporter into the records with the pitch, and files what comes back as the story's draft. */
effects/draft-story.ts:129: headline: z.string(),
effects/draft-story.ts:141: const leads = (story.leads ?? []).map((l) => byId.get(l._ref)).filter((r): r is DeskRecord => !!r)
effects/draft-story.ts:144: `Section: ${story.section ?? 'Front page'}`,
effects/draft-story.ts:145: `Working headline: ${story.headline ?? '(none)'}`,
effects/draft-story.ts:146: `Pitch: ${story.pitch ?? '(none)'}`,
effects/draft-story.ts:147: leads.length ? `Leads from the pitch:\n${leads.map(renderRecord).join('\n')}` : '',
effects/draft-story.ts:186: headline: filed.headline,
effects/draft-story.ts:192: ctx.log(`filed "${filed.headline}" after ${calls} tool calls`)
566: actions: ActionEvaluation[];
996: actions: v.SchemaWithPipe<
1226: actions: AvailableAction[];
2813: actions: string[];
5147: actions: v.ArraySchema<
/** Whether this activity is the current actor's responsibility right now. */
pendingOnActor: boolean;
/**
* The stage-entry `filter` scoped this activity out of the current visit:
* its entry is `skipped` and never started, so it does not exist for this
* visit — surfaces hide it entirely (an action-resolved `skipped` was real,
* started work and stays visible). Derived via {@link isFilterScopedOut},
* stamped here so consumers never re-join entry state to compute it.
*/
scopedOut: boolean;
/**
* Unmet authored activity requirements and required-reference readability checks.
* Present when at least one is unmet. Required-reference descriptors use
* `field:<name>` and the field's declared title when available.
*/
unmetRequirements?: RequirementDescriptor[];
/** Derived state per authored requirement, keyed by its declared name.
* Present when the activity declares requirements. Unmet authored requirements
* appear in `unmetRequirements`; required-reference checks
[... 719 more characters trimmed]
$ sed -n 74,120p effects/draft-story.ts; sed -n 175,195p effects/draft-story.ts
/** Sends a Claude reporter into the records with the pitch, and files what comes back as the story's draft. */
export const draftStory: EffectHandler = async (params, ctx) => {
const id = subjectId(params)
const [story, morgue] = await Promise.all([loadStory(id), loadMorgue()])
const byId = new Map(morgue.records.map((r) => [r._id, r]))
const text = (t: string) => ({content: [{type: 'text' as const, text: t}]})
const morgueServer = createSdkMcpServer({
name: 'morgue',
tools: [
tool(
'search_records',
'Search the colony records. Every filter is optional and they combine. Returns at most 60 records, oldest first.',
{
words: z.string().optional().describe('words that must all appear in the record text or label'),
colonist: z.string().optional().describe('a colonist name or nickname'),
fromDay: z.number().optional(),
toDay: z.number().optional(),
kind: z.enum(['tale', 'letter', 'message', 'talk']).optional(),
},
async ({words, colonist, fromDay, toDay, kind}) => {
const needles = (words ?? '').toLowerCase().split(/\s+/).filter(Boolean)
const who = colonist ? morgue.pawns.filter((p) => p.aliases?.some((a) => a.toLowerCase() === colonist.toLowerCase())) : []
const hits = morgue.records.filter((r) => {
const hay = `${r.label ?? ''} ${r.text ?? ''}`.toLowerCase()
return (
needles.every((n) => hay.includes(n)) &&
(!colonist || r.pawns?.some((p) => who.some((w) => w._id === p._ref))) &&
(fromDay === undefined || (r.colonyDay ?? 0) >= fromDay) &&
(toDay === undefined || (r.colonyDay ?? 0) <= toDay) &&
(!kind || r.kind === kind)
)
})
const shown = hits.slice(0, 60).map(renderRecord).join('\n')
return text(hits.length > 60 ? `${hits.length} matches, first 60:\n${shown}` : shown || 'No records match.')
},
),
tool('who_is', 'Look up a colonist or visitor by any of their names.', {name: z.string()}, async ({name}) => {
const found = morgue.pawns.filter((p) => p.aliases?.some((a) => a.toLowerCase().includes(name.toLowerCase())))
if (!found.length) return text(`Nobody called ${name} in the records.`)
return text(
found
.map((p) => {
const theirs = morgue.records.filter((r) => r.pawns?.some((ref) => ref._ref === p._id))
[... 973 more characters trimmed]
D:\Repos\web-apps\estian-tattler\nightdesk\src\App.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\App.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\nightdesk\src\Board.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\nightdesk\src\Board.tsx (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\nightdesk\src\RunPanel.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\nightdesk\src\RunPanel.tsx (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
File created successfully at: D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\nightdesk\src\App.css
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\App.css has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && cat tsconfig.json 2>/dev/null | head -30; npx tsc --noEmit -p . 2>&1 | head -40
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "Preserve",
"moduleDetection": "force",
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
src/Board.tsx(19,78): error TS2339: Property 'message' does not exist on type '{}'.
src/Coverage.tsx(59,11): error TS2322: Type 'string' is not assignable to type 'undefined'.
src/Coverage.tsx(60,11): error TS2322: Type 'string' is not assignable to type 'undefined'.
src/Coverage.tsx(61,11): error TS2322: Type '{ _key: string; _type: string; _ref: string; }[]' is not assignable to type 'undefined'.
src/Coverage.tsx(67,25): error TS2322: Type '{ type: "subject"; name: string; value: { id: string; type: string; }; }' is not assignable to type 'InitialFieldValue'.
The types of 'value.id' are incompatible between these types.
Type 'string' is not assignable to type '`dataset:${string}` | `canvas:${string}` | `media-library:${string}` | `dashboard:${string}`'.
src/RunPanel.tsx(85,65): error TS2339: Property 'message' does not exist on type '{}'.
Typechecking the Night Desk app and fixing the few type errors.
$ grep -n "type ObserverError\|interface ObserverError" -A12 node_modules/@sanity/workflow-react/dist/index.d.ts | head -20
143:declare type ObserverError = NonNullable<unknown>;
144-
145-/** Every outcome of a reactive read, normalized across Studio's RxJS streams
146- * and the App SDK's state sources. Validation failures are distinct from read
147- * failures: the former received data the engine cannot interpret; the latter
148- * did not receive a current value at all. */
149-declare type ObserverState<T> =
150- | {
151- status: "loading";
152- }
153- | {
154- status: "ready";
155- value: T;
$ sed -i 's/failed to load: {error.message}/failed to load: {String(error)}/' src/Board.tsx && sed -i 's/Lost the run: {error.message}/Lost the run: {String(error)}/' src/RunPanel.tsx && sed -i 's/export const storyGdr = (id: string) => `dataset:${PROJECT_ID}:${DATASET}:${id}`/export const storyGdr = (id: string) => `dataset:${PROJECT_ID}:${DATASET}:${id}` as const/' src/desk.ts && grep -n "storyGdr" src/desk.ts
10:export const storyGdr = (id: string) => `dataset:${PROJECT_ID}:${DATASET}:${id}` as const
Now the typed create in Coverage.
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ npx tsc --noEmit -p . 2>&1 | head -20; rm -f tsconfig.tsbuildinfo
(Bash completed with no output)
Typecheck is clean. Building and deploying the app to the Sanity dashboard.
$ npm run build 2>&1 | tail -15
> nightdesk@1.0.0 build
> sanity build
- Clean output folder
[32m✔[39m Clean output folder (1ms)
- Building Sanity application
[32m✔[39m Build Sanity application (5274ms)
$ npm run deploy -- --create --title "Night Desk" --yes --json 2>&1 | tail -30
deployment: {
appId: 'c7c1x4pxf78nzfgaxd3ql5os',
}
{
"deployed": true,
"reason": null,
"action": "create",
"application": {
"id": "c7c1x4pxf78nzfgaxd3ql5os",
"projectId": null,
"organizationId": "oj47h4o89",
"title": "Night Desk",
"type": "coreApp",
"urlType": "internal",
"appHost": "vm0w2ew6j526",
"dashboardStatus": "default",
"createdAt": "2026-09-25T14:13:44.833Z",
"updatedAt": "2026-09-25T14:13:44.833Z",
"autoUpdatingVersion": null
},
"payload": {
"appId": null,
"isAutoUpdating": false,
"organizationId": "oj47h4o89",
"type": "coreApp",
"version": "3.5.0"
},
"url": "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os"
}
$ cat > sanity.cli.ts <<'EOF'
import {defineCliConfig} from 'sanity/cli'
export default defineCliConfig({
app: {
organizationId: 'oj47h4o89',
entry: './src/App.tsx',
},
deployment: {
appId: 'c7c1x4pxf78nzfgaxd3ql5os',
},
})
EOF
cd /c/tmp/demo-shots && sed -i "s#/sanity\\\\.studio/.test(t.url)#new RegExp(process.env.FRAME || 'sanity\\\\\\\\.studio').test(t.url)#" studio_do.mjs && grep -n "FRAME" studio_do.mjs
(Bash completed with no output)
C:\tmp\demo-shots\studio_do.mjs
The file C:\tmp\demo-shots\studio_do.mjs has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /c/tmp/demo-shots && FRAME='.' node -e "
" ; node --input-type=module -e "
import { connect } from '../bugsmash/cdp.mjs';
const tab = await (await fetch('http://127.0.0.1:9222/json/new?about:blank', { method: 'PUT' })).json();
const c = connect(tab.webSocketDebuggerUrl); await c.ready;
try { await c.send('Emulation.setDeviceMetricsOverride', { width: 1500, height: 950, deviceScaleFactor: 1, mobile: false });
await c.navigate('https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os', 20000);
await new Promise(r=>setTimeout(r,8000));
const t = await (await fetch('http://127.0.0.1:9222/json/list')).json();
console.log(t.filter(x=>x.type==='iframe').map(x=>x.url.slice(0,120)).join('\n'));
} finally { c.close(); await fetch('http://127.0.0.1:9222/json/close/'+tab.id); }"
about:srcdoc
https://vm0w2ew6j526.sanity.studio/?_context=%7B%22env%22%3A%22production%22%2C%22mode%22%3A%22core-ui%22%2C%22orgId%22%
https://api.vector.co/pixel/phoenix/sync-container?duid=cfe2c6c9145d--01m3c4myjqf6gnbmdx1anxt747&ds=did-006l&euns=1&s=Cg
https://www.google.com/recaptcha/enterprise/anchor?ar=2&k=6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ&co=aHR0cHM6Ly93d3cucm
https://www.google.com/recaptcha/enterprise/anchor?ar=2&k=6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ&co=aHR0cHM6Ly93d3cucm
https://www.google.com/recaptcha/enterprise/anchor?ar=2&k=6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ&co=aHR0cHM6Ly93d3cucm
https://www.google.com/recaptcha/enterprise/anchor?ar=2&k=6LfirrMoAAAAAHZOipvza4kpp_VtTwLNuXVwURNQ&co=aHR0cHM6Ly93d3cucm
Deployed. Now screenshotting it from my own tab to see if it actually renders.
$ FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd1.png "document.body.innerText.slice(0,1500)" "document.querySelectorAll('.card').length"
> "The Night Desk\nEvery story the Estian Tattler has in the works, and every colonist it hasn't got to yet.\nREPORTING 0\nFACT-CHECK 0\nEDITOR'S DESK 0\nON THE PRESS 0\nPRINTED 4\nEdition 4 · 12m ago\nEastern Republic sours on the restaurant: \"Score: -12\"\nEdition 3 · 12m ago\nWho hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo\nEdition 2 · 13m ago\nThe berry pile keeps rotting\nEdition 1 · 32m ago\nSnake and Grasshopper: the marriage, the split, the binges\nSPIKED 0\n\nPick a story on the board to read it and decide.\n\nWho we haven't covered\n\nPrinted stories cite 100 of 631 records.\n\nReiraborvas 'Grasshopper' Canga\n24/136\nHyena Embaixada\n14/114\nSnake Rato\n24/113\nRocaniraalbo Ambmea\n16/91\nFlubber Flubber\n0/10\nPitch\nBurger Unkown\n0/6\nPitch\nEstian Hough\n0/3\nPitch\nCharites Decrescendo\n1/30\nMarcellina Triarius\n0/29\nPitch\nAquila Summanus\n0/24\nPitch\nCharango Paphianne\n0/18\nPitch\nIola 'Smalls' Schaefer\n0/14\nPitch\nBlack Hawk\n0/9\nPitch"
> 4
banner hidden: 1
C:\tmp\demo-shots\nd1.png
[image removed — image/png, stripped by compact-transcripts]
$ python /c/tmp/q.py '*[_type=="pawn" && name in ["Flubber Flubber","Marcellina Triarius","Aquila Summanus"]]{name, relationNote, gender, "recs": *[_type=="record" && references(^._id)] | order(tick asc){colonyDay, kind, label, text}}' | python -c "
import sys,json
for l in sys.stdin:
d=json.loads(l); print('==',d['name'],d.get('relationNote'),d.get('gender'))
for r in d['recs']: print(' ',r['colonyDay'],r['kind'],(r.get('label') or '')[:40],'|',(r.get('text') or '')[:110])"
== Marcellina Triarius None None
304 talk | Aquila Summanus chatted with Marcellina Triarius.
304 talk | Marcellina Triarius chatted with Aquila Summanus.
304 talk | Reiraborvas 'Grasshopper' Canga chatted with Marcellina Triarius.
304 talk | Marcellina Triarius chatted with Charites Decrescendo.
304 talk | Reiraborvas 'Grasshopper' Canga chatted with Marcellina Triarius.
304 talk | Charites Decrescendo chatted with Marcellina Triarius.
304 talk | Iola 'Smalls' Schaefer chatted with Marcellina Triarius.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Aquila Summanus insulted Marcellina Triarius.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Iola 'Smalls' Schaefer chatted with Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Charites Decrescendo.
305 talk | Charango Paphianne insulted Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Charango Paphianne.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Aquila Summanus chatted with Marcellina Triarius.
305 talk | Aquila Summanus chatted with Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Aquila Summanus.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Charango Paphianne.
305 talk | Marcellina Triarius chatted with Aquila Summanus.
305 talk | Marcellina Triarius chatted with Iola 'Smalls' Schaefer.
305 talk | Charites Decrescendo chatted with Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Charites Decrescendo.
305 talk | Marcellina Triarius chatted with Charites Decrescendo.
305 talk | Marcellina Triarius chatted with Charites Decrescendo.
305 talk | Iola 'Smalls' Schaefer chatted with Marcellina Triarius.
305 talk | Marcellina Triarius chatted with Aquila Summanus.
== Aquila Summanus None None
304 talk | Aquila Summanus chatted with Reiraborvas 'Grasshopper' Canga.
304 talk | Aquila Summanus chatted with Marcellina Triarius.
304 talk | Marcellina Triarius chatted with Aquila Summanus.
304 talk | Aquila Summanus chatted with Charango Paphianne.
304 talk | Charango Paphianne chatted with Aquila Summanus.
304 talk | Charango Paphianne insulted Aquila Summanus.
[... 1985 more characters trimmed]
Flubber Flubber is a dead colonist whose grave got 10 visits, 5 of them from Rocaniraalbo. That's a real Graves story. Pitching it from the app.
$ FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd2.png "(() => { const li = [...document.querySelectorAll('.coverage li')].find(l => l.textContent.includes('Flubber Flubber')); li.querySelector('button').click(); return 'clicked' })()" "document.querySelector('.failure')?.textContent || 'no failure'" "[...document.querySelectorAll('.column')].map(c => c.innerText.replace(/\n/g,' | ')).join(' ## ')" "document.querySelector('.run')?.innerText.slice(0,600)"
> "clicked"
> "no failure"
> "REPORTING 1 | No section · just now | Flubber Flubber ## FACT-CHECK 0 ## EDITOR'S DESK 0 ## ON THE PRESS 0 ## PRINTED 4 | Edition 4 · 14m ago | Eastern Republic sours on the restaurant: \"Score: -12\" | Edition 3 · 14m ago | Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo | Edition 2 · 14m ago | The berry pile keeps rotting | Edition 1 · 34m ago | Snake and Grasshopper: the marriage, the split, the binges ## SPIKED 0"
> "Pick a story on the board to read it and decide."
banner hidden: 1
The pitch landed: a new run is in Reporting. The run panel didn't open on its own, though. Checking the documents first, then that.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'raw'});
c.fetch('{\"s\":*[_id match \"*story-human99323*\"]{_id,headline,pitch,\"leads\":leads[]._ref},\"i\":*[_type==\"sanity.workflow.instance\" && !defined(completedAt)]{_id,currentStage,pendingEffects,\"sub\":fields[name==\"subject\"][0].value, history[]{_type,executionContext}}}').then(d=>console.log(JSON.stringify(d,null,1).slice(0,3000)))"
{
"i": [
{
"_id": "production.wf-instance.d7814ba3701e",
"currentStage": "reporting",
"history": [
{
"_type": "stageEntered",
"executionContext": {
"kind": "sdk",
"runtime": "browser"
}
},
{
"_type": "actionFired",
"executionContext": {
"kind": "sdk",
"runtime": "browser"
}
},
{
"_type": "opApplied",
"executionContext": {
"kind": "sdk",
"runtime": "browser"
}
},
{
"_type": "effectQueued",
"executionContext": {
"kind": "sdk",
"runtime": "browser"
}
}
],
"pendingEffects": [
{
"_key": "afb4dc7d2633",
"_type": "pendingEffect",
"actor": {
"id": "gqD1X1lmX",
"kind": "person",
"roles": [
"administrator"
]
},
"bindings": {
"note": "$fields.deskNote",
"subject": "$fields.subject._id"
},
"name": "draft-story",
"origin": {
"kind": "action",
"name": "assign"
},
"params": {
"note": null,
"subject": "dataset:lcvgtfvq:production:story-human99323-muh1mzu3"
},
"queuedAt": "2026-09-25T14:16:07.036Z",
"stageEntryKey": "1143b5ede584"
}
],
"sub": {
"id": "dataset:lcvgtfvq:production:story-human99323-muh1mzu3",
"type": "story"
}
}
],
"s": [
{
"_id": "drafts.story-human99323-muh1mzu3",
"headline": "Flubber Flubber",
"leads": [
"record-tale-1088",
"record-tale-1054",
"record-tale-1045",
"record-tale-925",
"record-tale-911"
],
"pitch": "The paper hasn't printed a word about Flubber Flubber. Start from these records and find the story."
}
]
}
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd3.png "document.querySelectorAll('.card')[1].click(), 'ok'" "document.querySelector('.lower').innerText.slice(0,900)"
> "ok"
> "PRINTED\nDrafts filed: 1 · Open in Studio\n×\nEastern Republic sours on the restaurant: \"Score: -12\"\n\nFrom a perfect 100 to a frosty farewell, the colony's dining trade has had a bumpy season.\n\nThe colony restaurant has a review problem, and it has a name: the Eastern Republic.2\n\nOn Day 305, visitors from the Eastern Republic packed up and left, and the verdict was brutal: \"They expected more from you. (Score: -12) They won't come back any time soon.\" 2Ouch. Somebody tell the kitchen.\n\nIt wasn't always this way. A group from the Eastern Republic arrived on Day 271, and on Day 273 the scorecard read \"They really enjoyed their stay! (Score: 59)\". 2So what changed between visits? The records aren't saying, and neither, apparently, are the guests.\n\nStranger still, the same departing party left a gift on Day 305: a wooden lute and a concrete lyre. 1A parting present and a scathing review in the "
banner hidden: 1
The panel opens fine from a card click, so the pitch path is losing its state. Watching the DOM right after a pitch click to see what resets it.
$ grep -n "evaluate" /c/tmp/bugsmash/cdp.mjs | head
7:// const title = await c.evaluate('document.title');
65: const evaluate = async (expression, awaitPromise = true) => {
66: const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise, userGesture: true });
70: // NOTE: Page.navigate / Runtime.evaluate work WITHOUT Page.enable/Runtime.enable.
81: const box = await evaluate(`(() => { const e=document.querySelector(${JSON.stringify(selector)}); if(!e) return null; e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect(); return {x:r.x+Math.min(30,r.width/2), y:r.y+Math.min(20,r.height/2)}; })()`);
98: evaluate(`(() => {
110: return { ready, send, evaluate, navigate, sleep, clickXY, clickSelector, insertText, key, setReactValue, screenshot,
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows --help 2>&1 | head -40
Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.
VERSION
@sanity/workflow-cli/0.35.0 win32-x64 node-v22.18.0
USAGE
$ sanity-workflows [COMMAND]
COMMANDS
help
Display help for sanity-workflows.
abort
Abort an in-flight workflow instance — a hard stop: pending effects are
cancelled, stage guards removed, and the instance is marked terminal where
it stands.
blueprint generate
Experimental: generate the Sanity Blueprints runtime the definitions
require, next to sanity.workflow.ts. Writes the workflow resources, one
function per derived need, the effect-handler registry, and a handler stub
per declared effect. Covers every deployment in the config, because the
emitted resources module declares them all. These flags and this output may
change before the Blueprints backend accepts the sanity.workflow resource.
definition delete
Delete a deployed workflow definition (every version, or one via --version).
Refuses while non-terminal instances exist unless --cascade aborts them
first — instances are aborted in place, never deleted.
definition diff
Diff an in-code definition against the deployed version (latest by default).
definition list
List deployed workflow definitions.
definition show
Show a deployed workflow definition.
deploy
$ set -a && . ./.env && set +a && npx sanity-workflows abort --help 2>&1 | sed -n 1,25p
Abort an in-flight workflow instance — a hard stop: pending effects are cancelled, stage guards removed, and the instance is marked terminal where it stands.
USAGE
$ sanity-workflows abort INSTANCEID [--deployment <value> | --tag
<value>] [--reason <value>]
ARGUMENTS
INSTANCEID Workflow instance id.
FLAGS
--deployment=<value> Deployment name — narrow the instance search to the
resource that deployment targets; the tag partition
still comes from the loaded instance.
--reason=<value> Reason for aborting (recorded in history).
--tag=<value> Workflow environment tag (e.g. prod, test) — an optional
query filter that also narrows which resources are
searched; omit to span them all.
DESCRIPTION
Abort an in-flight workflow instance — a hard stop: pending effects are
cancelled, stage guards removed, and the instance is marked terminal where it
stands.
EXAMPLES
$ sanity-workflows abort wf-instance.abc123
$ set -a && . ./.env && set +a && npx sanity-workflows abort d7814ba3701e --tag production --reason "Re-pitching with a readable story id" 2>&1 | tail -5 && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false});
c.delete('drafts.story-human99323-muh1mzu3').then(r=>console.log('deleted',r.results.length))"
[31m✖[39m Workflow instance d7814ba3701e not found
deleted 1
$ set -a && . ./.env && set +a && npx sanity-workflows abort wf-instance.d7814ba3701e --tag production --reason "Re-pitching with a readable story id" 2>&1 | tail -5
[31m✖[39m Workflow instance wf-instance.d7814ba3701e not found
$ set -a && . ./.env && set +a && npx sanity-workflows list --tag production 2>&1 | tail -8; npx sanity-workflows abort production.wf-instance.d7814ba3701e --tag production --reason "Re-pitching with a readable story id" 2>&1 | tail -3
instance workflow tag stage status updated
production.wf-instance.d7814ba3701e story-desk production reporting in-flight 3 minutes ago
- Aborting production.wf-instance.d7814ba3701e…
[32m✔[39m Aborted — instance hard-stopped at reporting
Aborted the test run. Now fixing the story id slug, hiding aborted runs from the board, and then re-pitching while watching the panel state.
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && python - <<'EOF'
import re
p='src/Coverage.tsx'; s=open(p).read()
s=s.replace(""" const storyId = `story-${bareId(pawn._id).replace(/^pawn-/, '').toLowerCase()}-${Date.now().toString(36)}`""",""" const storyId = `story-${pawn.name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '')}`""")
s=s.replace("import {WORKFLOW, bareId, storyGdr} from './desk'","import {WORKFLOW, storyGdr} from './desk'")
open(p,'w').write(s)
p='src/Board.tsx'; s=open(p).read()
s=s.replace("const runs = [...instances].sort(","const runs = instances.filter((r) => !r.abortedAt).sort(")
open(p,'w').write(s)
EOF
grep -n "storyId =\|abortedAt" src/*.tsx; npx tsc --noEmit -p . && rm -f tsconfig.tsbuildinfo && npm run build 2>&1 | tail -2 && npm run deploy -- --yes 2>&1 | tail -3
src/Board.tsx:24: const runs = instances.filter((r) => !r.abortedAt).sort((a, b) => b.lastChangedAt.localeCompare(a.lastChangedAt))
src/Coverage.tsx:62: const storyId = `story-${pawn.name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '')}`
src/RunPanel.tsx:90: const storyId = subjectOf(instance)
- Building Sanity application
[32m✔[39m Build Sanity application (637ms)
Success! Application deployed to https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os — "Night Desk"
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd4.png "(() => { const li = [...document.querySelectorAll('.coverage li')].find(l => l.textContent.includes('Flubber Flubber')); li.querySelector('button').click(); return li.innerText })()" "(async () => { const log = []; for (let i = 0; i < 16; i++) { const li = [...document.querySelectorAll('.coverage li')].find(l => l.textContent.includes('Flubber Flubber')); log.push(i + ': ' + (li ? li.innerText.replace(/\n/g,' ') : 'NO LI') + ' || ' + document.querySelector('.lower').innerText.slice(0,60).replace(/\n/g,' ') + ' || fail=' + (document.querySelector('.failure')?.textContent || '')); await new Promise(r => setTimeout(r, 500)) } return log.join('\n') })()"
> "Flubber Flubber\n0/10\nPitch"
> "0: Flubber Flubber 0/10 Pitching… || Pick a story on the board to read it and decide. Who we hav || fail=\n1: Flubber Flubber 0/10 Pitching… || Pick a story on the board to read it and decide. Who we hav || fail=\n2: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n3: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n4: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n5: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n6: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n7: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n8: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n9: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n10: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n11: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n12: Flubber Flubber 0/10 on the desk || Pick a story on the board to read it and decide. Who we hav || fail=\n13: Flubber Flubber 0/10 on the desk || Pick a story on the board to r
banner hidden: 1
$ cd /d/Repos/web-apps/estian-tattler/nightdesk/node_modules/@sanity/workflow-engine/dist && ls; grep -n "startInstance" index.js | head -20
_chunks-cjs
_chunks-es
define.cjs
define.d.cts
define.d.ts
define.js
index.cjs
index.d.cts
index.d.ts
index.js
3923: super("start-not-allowed", `startInstance refused definition "${args.definition}": ${args.unmetRequirements.map(requirement => requirement.title ?? requirement.name).join(", ")}. Pre-flight the verdict with evaluateStart.`),
12060: if (mismatch !== void 0) throw new ContractViolationError(`startInstance: instanceId "${existing._id}" already exists and ${mismatch}. A supplied instanceId is start's idempotency key — reuse one only to retry the same start.`);
12061: if (existing.stages.length === 0 && terminalState(existing) !== "in-flight") throw new ContractViolationError(`startInstance: instanceId "${existing._id}" belongs to a start that was discarded (aborted before it finished starting) — mint a new id to start again.`);
12522: startInstance: async rawArgs => {
14580: startInstance: rest => workflow.startInstance(withScope(rest)),
$ sed -n 12522,12600p index.js
startInstance: async rawArgs => {
const args = taggedScope(rawArgs, REQUEST_TAG.start), {client: client, tag: tag, definition: definitionName, version: version, initialFields: initialFields, instanceId: instanceId, executionContext: executionContext} = args, operationContext = await resolveOperationContext(args), {actor: actor, clientForGdr: clientForGdr, refSurface: refSurface, memberRolesLoader: memberRolesLoader} = operationContext, clock = args.clock ?? wallClock, seedFields = initialFields ?? [], existing = instanceId !== void 0 ? await getInstanceDocument(client, instanceId) : void 0;
return existing !== void 0 && existing.tag === tag ? resumeStart({
client: client,
tag: tag,
existing: existing,
definition: definitionName,
version: version,
initialFieldCount: seedFields.length,
actor: actor,
clientForGdr: clientForGdr,
refSurface: refSurface,
memberRolesLoader: memberRolesLoader,
clock: clock,
...executionContext !== void 0 ? {
executionContext: executionContext
} : {},
...args.telemetry !== void 0 ? {
telemetry: args.telemetry
} : {}
}) : startFreshInstance({
args: args,
operationContext: operationContext,
clock: clock,
seedFields: seedFields
});
},
fireAction: async rawArgs => {
const args = taggedScope(rawArgs, REQUEST_TAG.fireAction), {client: client, tag: tag, workflowResource: workflowResource, instanceId: instanceId, activity: activity, action: action, params: params, idempotent: idempotent, resourceClients: resourceClients, grantsFromPath: grantsFromPath, executionContext: executionContext} = args;
return runCommitVerb({
args: args,
op: "fireAction",
run: async ({access: access, actor: actor, clientForGdr: clientForGdr, refSurface: refSurface, memberRolesLoader: memberRolesLoader, clock: clock, now: now, record: record, before: before}) => {
if (findCurrentActivityEntry(before, activity) === void 0 && idempotent === !0) return settledResult({
instance: before,
cascaded: 0,
changed: !1,
nextEvaluationAt: await unchangedNextEvaluationAt({
client: client,
[... 1785 more characters trimmed]
$ grep -n "export declare function instanceDocId" -B8 -A4 index.d.ts; sed -n 7980,7995p index.d.ts
5639-/**
5640- * Mint the Sanity document `_id` for a workflow instance — a fresh
5641- * random suffix, so every instance (root or spawned child) gets a
5642- * unique doc id under its tag. Lives in the shell, not `core/`, because it
5643- * draws randomness; the deterministic `definitionDocId` is the pure-core
5644- * counterpart. Bare form — Sanity rejects `:` in document IDs, so this is
5645- * never a GDR URI.
5646- */
5647:export declare function instanceDocId(tag: string): string;
5648-
5649-/**
5650- * The per-instance guard filter — "this instance's guards in one datasource".
5651- * The engine's verdict load ({@link verdictGuardsForInstance}) fetches it once
* Caller-supplied id; auto-generated otherwise (mint one with
* `instanceDocId`). This is start's idempotency key: a retry carrying the
* same id resumes the earlier attempt's outstanding commits instead of
* creating a duplicate — the per-instance request ledger can't cover the
* create (it lives on the instance document), so id identity is the rail.
* Inputs bind on the create only; a resume never re-reads them.
*/
instanceId?: string;
/**
* URL path on the supplied client where the engine fetches the
* caller's ACL grants. Identity is always token-resolved — `actor` is
* resolved from the supplied client's token, never passed in — and grants
* feed the advisory `$can.*` params action conditions can read;
* omitting this leaves the rendered `$can` undefined (conditions
* referencing it fail closed, nothing else is gated engine-side —
* the real Sanity write boundary still enforces).
Minting the instance id up front so the panel can open straight away, and not blocking the UI on the start promise. Meanwhile I'm running the desk runner so the reporter drafts the Flubber story.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && timeout 400 npm run desk -- --once 2>&1 | tail -15
Command running in background with ID: b3jmkjj90. Output is being written to: C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\b3jmkjj90.output. You will be notified when it completes. To check interim output, use Read on that file path.
Session cwd remains D:\Repos\web-apps\estian-tattler\nightdesk\node_modules\@sanity\workflow-engine\dist; directory changes made by the backgrounded command do not apply to subsequent commands.
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && sed -i "s#^import type {Engine} from '@sanity/workflow-engine'#import {instanceDocId, type Engine} from '@sanity/workflow-engine'#; s#^import {WORKFLOW, storyGdr} from './desk'#import {TAG, WORKFLOW, storyGdr} from './desk'#" src/Coverage.tsx && sed -i "s#^export const WORKFLOW = 'story-desk'#export const WORKFLOW = 'story-desk'\nexport const TAG = 'production'#" src/desk.ts && sed -i "s#useWorkflowEngine({workflowResource, tag: 'production'})#useWorkflowEngine({workflowResource, tag: TAG})#; s#import {DATASET, PROJECT_ID, workflowResource} from './desk'#import {DATASET, PROJECT_ID, TAG, workflowResource} from './desk'#" src/App.tsx && head -5 src/Coverage.tsx && grep -n TAG src/*.ts* && npx tsc --noEmit -p . ; rm -f tsconfig.tsbuildinfo
import {useState} from 'react'
import {useCreateDocument, useQuery} from '@sanity/sdk-react'
import {instanceDocId, type Engine} from '@sanity/workflow-engine'
import {TAG, WORKFLOW, storyGdr} from './desk'
src/App.tsx:7:import {DATASET, PROJECT_ID, TAG, workflowResource} from './desk'
src/App.tsx:11: const engine = useWorkflowEngine({workflowResource, tag: TAG})
src/Coverage.tsx:4:import {TAG, WORKFLOW, storyGdr} from './desk'
src/Coverage.tsx:72: const instanceId = instanceDocId(TAG)
src/desk.ts:6:export const TAG = 'production'
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ npx tsc --noEmit -p . && rm -f tsconfig.tsbuildinfo && npm run build 2>&1 | tail -1 && npm run deploy -- --yes 2>&1 | tail -2
[32m✔[39m Build Sanity application (640ms)
Success! Application deployed to https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os — "Night Desk"
Updated the existing application.
$ ls -la dist dist/static 2>/dev/null | head; grep -l "hasn't printed a word" -r dist | head -3; grep -o "on the desk" -r dist | head -2
dist:
total 20
drwxr-xr-x 1 owner 197609 0 Sep 25 22:21 .
drwxr-xr-x 1 owner 197609 0 Sep 25 22:21 ..
-rw-r--r-- 1 owner 197609 1200 Sep 25 22:07 favicon.ico
-rw-r--r-- 1 owner 197609 5905 Sep 25 22:21 index.html
drwxr-xr-x 1 owner 197609 0 Sep 25 22:21 static
dist/static:
total 1294
dist/static/sanity-Df7a8ELp.js
dist/static/sanity-Df7a8ELp.js:on the desk
dist/static/sanity-Df7a8ELp.js:on the desk
Build is fresh. Checking on the reporter.
$ sleep 60; cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/b3jmkjj90.output"
<tool_use_error>Blocked: sleep 60 followed by: cat "C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/b3jmkjj90.output". To wait for a condition, use Monitor with an until-loop (e.g. `until <check>; do sleep 2; done`). To wait for a command you started, use run_in_background: true. Do not chain shorter sleeps to work around this block.</tool_use_error>
C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\b3jmkjj90.output
While the reporter works, I'll write up the Night Desk in the README and BUILDLOG.
$ cd /d/Repos/web-apps/estian-tattler && cat nightdesk/.gitignore 2>/dev/null | head; cat .gitignore; git status --short | head
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
/node_modules
/.pnp
.pnp.js
# Compiled Sanity Studio
/dist
node_modules/
.env
*.local
dist/
.sanity/
.next/
out/
next-env.d.ts
*.tsbuildinfo
?? nightdesk/
$ python - <<'EOF'
p='README.md'; s=open(p,encoding='utf-8').read()
s=s.replace("""Stories get pitched from the Studio with the "Pitch a story" action on any record.
""","""Stories get pitched from the Studio with the "Pitch a story" action on any record, or from the Night Desk.
## The Night Desk
`nightdesk/` is an App SDK app that runs in the Sanity Dashboard. It's the editor's view of the whole paper rather than one story:
- A board with every run in its current stage, live from `useWorkflowInstances`.
- A run panel driven by `useWorkflowSession`. It shows the story with every claim marked with how many records back it, and the editor's buttons come from the session's evaluation, so Send back asks for its note because the action declares that param.
- A coverage meter: how many of each colonist's records the printed stories cite. Anyone at zero gets a Pitch button, which creates the story with their records as leads and starts a `story-desk` run for it.
Dashboard apps are only visible to members of the organization, so `docs/nightdesk.png` is what it looks like.
""")
s=s.replace("""- `frontpage/` is a static Next.js site.""","""- `nightdesk/` is the App SDK dashboard app described above.
- `frontpage/` is a static Next.js site.""")
open(p,'w',encoding='utf-8').write(s)
EOF
grep -n "nightdesk\|Night Desk" README.md
38:Stories get pitched from the Studio with the "Pitch a story" action on any record, or from the Night Desk.
40:## The Night Desk
42:`nightdesk/` is an App SDK app that runs in the Sanity Dashboard. It's the editor's view of the whole paper rather than one story:
48:Dashboard apps are only visible to members of the organization, so `docs/nightdesk.png` is what it looks like.
55:- `nightdesk/` is the App SDK dashboard app described above.
C:\Users\owner\AppData\Local\Temp\claude\C--Users-owner\cb5e482e-d893-4cd2-9e25-7f331f362c4c\tasks\b3jmkjj90.output
Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.
{
"query": "select:Monitor",
"max_results": 1
}
[image]
$ f="C:/Users/owner/AppData/Local/Temp/claude/C--Users-owner/cb5e482e-d893-4cd2-9e25-7f331f362c4c/tasks/b3jmkjj90.output"; until [ -s "$f" ]; do sleep 3; done; cat "$f"
> estian-newsroom@1.0.0 desk
> tsx --env-file=.env desk-runner.ts --once
[effect.draft-story] filed "Flubber Flubber's Grave Draws a Faithful Few" after 8 tool calls
[effect.fact-check] passed: 8 claims, 0 problems
production.wf-instance.d1ed48a34057 draft-story done (81s)
production.wf-instance.d1ed48a34057 fact-check done (81s)
[exited with code 0]
The reporter filed "Flubber Flubber's Grave Draws a Faithful Few" and it passed fact-check (8 claims). Before the editor decides in the app, I'm checking it by hand against the records.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'drafts'});
c.fetch('*[_id==\"story-flubber-flubber\"][0]{headline,dek,body[]{\"t\":children[]{text,marks},markDefs[]{_key,_type,\"r\":records[]->{colonyDay,text}}}}').then(d=>{console.log('H:',d.headline);console.log('D:',d.dek);for(const b of d.body){for(const s of b.t){const m=b.markDefs.find(m=>s.marks?.includes(m._key));console.log((m?m._type:'plain').padEnd(6),'|',s.text);if(m?.r)for(const r of m.r)console.log(' ->',r.colonyDay,r.text)}console.log()}})"
H: Flubber Flubber's Grave Draws a Faithful Few
D: From Day 123 to Day 269, colonists kept paying their respects to Flubber Flubber, and Rocaniraalbo Ambmea kept coming back
claim | The Tattler has never printed a word about Flubber Flubber. The colony's own records say little more, but they do keep track of who stops by his grave.
-> 123 Hyena Embaixada visited the grave of Flubber Flubber.
claim | The first visit in our files came on Day 123, when Hyena Embaixada went to the grave of Flubber Flubber.
-> 123 Hyena Embaixada visited the grave of Flubber Flubber.
aside | How he came to be buried there, the records don't say. Readers who remember are invited to write in.
claim | The most faithful visitor by far is Rocaniraalbo Ambmea, who went to the grave on Day 154, Day 192, Day 213, Day 230 and Day 235.
-> 154 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 192 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 213 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 230 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 235 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
claim | That makes 5 visits, more than anyone else on record.
-> 154 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 192 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 213 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 230 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 235 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
aside | Old friend? Kindred spirit? The records keep their counsel, and so, apparently, does she.
claim | Snake Rato made the trip on Day 232, squeezed in between Rocaniraalbo Ambmea's visits on Day 230 and Day 235.
-> 232 Snake Rato visited the grave of Flubber Flubber.
-> 230 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
-> 235 Rocaniraalbo Ambmea visited the grave of Flubber Flubber.
claim | Hyena Embaixada came back on Day 256, the second visit on file for the colonist who started the list.
-> 123 Hyena Embaixada visited the grave of Flubber Flubber.
-> 256 Hyena Embaixada visited the grave of Flubber Flubber.
claim | Then Reiraborvas 'Grasshopper' Canga took up the vigil, visiting on Day 259 and again on Day 269.
-> 259 Reiraborvas 'Grasshopper' Canga visited the grave of Flubber Fl
[... 474 more characters trimmed]
$ python /c/tmp/q.py '*[_type=="pawn" && name match "Rocaniraalbo*"]{name,gender,age}'
{
"age": 44,
"gender": "Female",
"name": "Rocaniraalbo Ambmea"
}
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd5.png "(() => { const card = [...document.querySelectorAll('.card')].find(c => c.textContent.includes('Flubber')); card.click(); return card.innerText })()" "(() => { const t = document.querySelector('.action-send-back textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, \"Three sentences say more than their receipts can: 'more than anyone else on record', 'the last one in the files' and 'the colony's own records say little more'. Cite every visit if you want the comparison, or turn them into questions.\"); t.dispatchEvent(new Event('input', {bubbles: true})); return [...document.querySelectorAll('.action button')].map(b => b.textContent + (b.disabled ? ' (disabled)' : '')).join(', ') })()"
> "No section · 1m ago\nFlubber Flubber's Grave Draws a Faithful Few"
> "Send to press, Send back, Spike it"
banner hidden: 1
C:\tmp\demo-shots\nd5.png
[image removed — image/png, stripped by compact-transcripts]
D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx
The file D:\Repos\web-apps\estian-tattler\nightdesk\src\Coverage.tsx has been updated successfully. (file state is current in your context — no need to Read it back)
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && npx tsc --noEmit -p . && rm -f tsconfig.tsbuildinfo && npm run build 2>&1 | tail -1 && npm run deploy -- --yes 2>&1 | tail -1
[32m✔[39m Build Sanity application (634ms)
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd6.png "(() => { [...document.querySelectorAll('.card')].find(c => c.textContent.includes('Flubber')).click(); return 'opened' })()" "(() => { const t = document.querySelector('.action-send-back textarea'); const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(t, \"Three sentences say more than their receipts can: 'more than anyone else on record', 'the last one in the files' and 'the colony's own records say little more'. Cite every visit if you want the comparison, or turn them into questions.\"); t.dispatchEvent(new Event('input', {bubbles: true})); return 'typed' })()" "(() => { const b = document.querySelector('.action-send-back button'); const was = b.disabled; b.click(); return 'clicked, disabled before=' + was })()" "(async () => { await new Promise(r => setTimeout(r, 4000)); return document.querySelector('.lower .run')?.innerText.slice(0, 200) + ' || fail=' + (document.querySelector('.failure')?.textContent || '') + ' || ' + [...document.querySelectorAll('.column')].map(c => c.querySelector('h2').innerText).join(', ') })()"
> "opened"
> "typed"
> "clicked, disabled before=false"
> "EDITOR'S DESK\nDrafts filed: 1 · Open in Studio\n×\nFlubber Flubber's Grave Draws a Faithful Few\n\nFrom Day 123 to Day 269, colonists kept paying their respects to Flubber Flubber, and Rocaniraalbo Ambmea || fail= || REPORTING 1, FACT-CHECK 0, EDITOR'S DESK 0, ON THE PRESS 0, PRINTED 4, SPIKED 0"
banner hidden: 1
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows show production.wf-instance.d1ed48a34057 --tag production 2>&1 | head -40
story-desk v1 production.wf-instance.d1ed48a34057
Stage: reporting
Started: 2026-09-25 22:19:36
Completed: —
Tag: production
Stages:
• reporting (exited 2026-09-25 22:21:53)
[32m✔[39m report [done]
• fact-check (exited 2026-09-25 22:22:05)
[32m✔[39m check [done]
• editor (exited 2026-09-25 22:24:24)
[32m✔[39m review [done]
• reporting (current)
● report [active]
Pending effects:
• draft-story (key=f832717100a1)
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && grep -n "tick:\|tick(" node_modules/@sanity/workflow-engine/dist/index.d.ts | head -5; sed -i "s# const \[open, setOpen\] = useState<string>()# const [open, setOpen] = useState<string>()\n ;(window as unknown as {__engine: unknown}).__engine = engine#" src/App.tsx && npm run build 2>&1 | tail -1 && npm run deploy -- --yes 2>&1 | tail -1
3686: tick: (args: OperationArgs) => Promise<OperationResult>;
5745: tick(): Promise<OperationResult>;
8981: tick: (
[32m✔[39m Build Sanity application (639ms)
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' - "(async () => { const e = window.__engine; if (!e) return 'no engine'; const t0 = performance.now(); const r = await Promise.race([e.tick({instanceId: 'production.wf-instance.a37f315f7d77'}).then(x => 'settled changed=' + x.changed, err => 'threw ' + err.message), new Promise(r => setTimeout(() => r('TIMEOUT 25s'), 25000))]); return r + ' after ' + Math.round(performance.now() - t0) + 'ms' })()"
> "settled changed=false after 3006ms"
banner hidden: 1
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && timeout 400 npm run desk -- --once 2>&1 | tail -8
> estian-newsroom@1.0.0 desk
> tsx --env-file=.env desk-runner.ts --once
[effect.draft-story] filed "Flubber Flubber's Grave Draws a Faithful Few" after 6 tool calls
[effect.fact-check] passed: 7 claims, 0 problems
production.wf-instance.d1ed48a34057 draft-story done (76s)
production.wf-instance.d1ed48a34057 fact-check done (76s)
$ set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'drafts'});
c.fetch('*[_id==\"story-flubber-flubber\"][0]{headline,dek,body[]{\"t\":children[]{text,marks},markDefs[]{_key,_type,\"r\":records[]->{colonyDay,text}}}}').then(d=>{console.log('H:',d.headline);console.log('D:',d.dek);for(const b of d.body){for(const s of b.t){const m=b.markDefs.find(m=>s.marks?.includes(m._key));console.log((m?m._type:'plain').padEnd(6),'|',s.text);if(m?.r)console.log(' ->',m.r.map(r=>r.colonyDay+' '+r.text.split(' visited')[0]).join('; '))}console.log()}})"
H: Flubber Flubber's Grave Draws a Faithful Few
D: Rocaniraalbo Ambmea keeps coming back, and a handful of others do too
claim | The Tattler has never printed a word about Flubber Flubber, but the colony's grave-visiting logs have been keeping his memory for us: ten visits to his grave, from Day 123 to Day 269.
-> 123 Hyena Embaixada; 154 Rocaniraalbo Ambmea; 192 Rocaniraalbo Ambmea; 213 Rocaniraalbo Ambmea; 230 Rocaniraalbo Ambmea; 232 Snake Rato; 235 Rocaniraalbo Ambmea; 256 Hyena Embaixada; 259 Reiraborvas 'Grasshopper' Canga; 269 Reiraborvas 'Grasshopper' Canga
aside | Who was he, and how did he end up there? The files we pulled don't say.
claim | The first recorded mourner was Hyena Embaixada, who visited the grave on Day 123.
-> 123 Hyena Embaixada
claim | Then came the regular. Rocaniraalbo Ambmea visited the grave on Day 154, Day 192, Day 213, Day 230 and Day 235 — five visits in all.
-> 154 Rocaniraalbo Ambmea; 192 Rocaniraalbo Ambmea; 213 Rocaniraalbo Ambmea; 230 Rocaniraalbo Ambmea; 235 Rocaniraalbo Ambmea
claim | Of all the logged visits, Rocaniraalbo Ambmea made more than any other visitor on record.
-> 123 Hyena Embaixada; 154 Rocaniraalbo Ambmea; 192 Rocaniraalbo Ambmea; 213 Rocaniraalbo Ambmea; 230 Rocaniraalbo Ambmea; 232 Snake Rato; 235 Rocaniraalbo Ambmea; 256 Hyena Embaixada; 259 Reiraborvas 'Grasshopper' Canga; 269 Reiraborvas 'Grasshopper' Canga
aside | That's devotion, or at least a very well-worn path.
claim | In between, Snake Rato stopped by on Day 232.
-> 232 Snake Rato
claim | Hyena Embaixada came back on Day 256, the second visit on record from the grave's first mourner.
-> 123 Hyena Embaixada; 256 Hyena Embaixada
claim | The newest face at the graveside is Reiraborvas 'Grasshopper' Canga, who visited on Day 259 and again on Day 269.
-> 259 Reiraborvas 'Grasshopper' Canga; 269 Reiraborvas 'Grasshopper' Canga
aside | And since then? Nothing in the logs we pulled — has the path gone quiet, or is someone still paying respects off the books?
aside | A faithful few, a lonely grave, and a story the records barely tell. If you knew the man, the Tattler would love to hear from you.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd7.png "(() => { [...document.querySelectorAll('.card')].find(c => c.textContent.includes('Flubber')).click(); return 'opened' })()" "(async () => { const t0 = performance.now(); const log = []; const stage = () => document.querySelector('.lower .run .section')?.textContent; log.push('before: ' + stage() + ' / buttons ' + [...document.querySelectorAll('.action button')].map(b => b.textContent + (b.disabled ? '(x)' : '')).join(',')); document.querySelector('.action-approve button').click(); let last = stage(); for (let i = 0; i < 90; i++) { await new Promise(r => setTimeout(r, 1000)); const s = stage(); const busy = !!document.querySelector('.actions.busy'); const key = s + (busy ? ' busy' : ''); if (key !== last) { log.push(Math.round((performance.now() - t0) / 1000) + 's: ' + key); last = key } if (s === 'Printed') break } log.push('fail=' + (document.querySelector('.failure')?.textContent || '')); return log.join(' | ') })()"
> "opened"
> "ERR timeout: Runtime.evaluate"
banner hidden: 1
My CDP helper timed out at its own limit before the poll finished, so the timing log is lost. Checking server state and the screenshot.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows show production.wf-instance.d1ed48a34057 --tag production 2>&1 | sed -n 1,30p; grep -n "timeout" /c/tmp/bugsmash/cdp.mjs | head -5
story-desk v1 production.wf-instance.d1ed48a34057
Stage: printing
Started: 2026-09-25 22:19:36
Completed: —
Tag: production
Stages:
• reporting (exited 2026-09-25 22:21:53)
[32m✔[39m report [done]
• fact-check (exited 2026-09-25 22:22:05)
[32m✔[39m check [done]
• editor (exited 2026-09-25 22:24:24)
[32m✔[39m review [done]
• reporting (exited 2026-09-25 22:27:40)
[32m✔[39m report [done]
• fact-check (exited 2026-09-25 22:27:52)
[32m✔[39m check [done]
• editor (exited 2026-09-25 22:28:37)
[32m✔[39m review [done]
• printing (current)
● press [active]
Pending effects:
• print (key=da5b2cff4f56)
59: if (pending.has(id)) { pending.delete(id); reject(new Error('timeout: ' + method)); }
C:\tmp\demo-shots\nd7.png
[image removed — image/png, stripped by compact-transcripts]
$ set -a && . ./.env && set +a && timeout 300 npm run desk -- --once 2>&1 | tail -4
> tsx --env-file=.env desk-runner.ts --once
[effect.print] printed story-flubber-flubber in edition 5
production.wf-instance.d1ed48a34057 print done (14s)
$ cd /d/Repos/web-apps/estian-tattler && gh run list --repo Booyaka101/estian-tattler --limit 3 2>&1 | head -5
in_progress print Front page main repository_dispatch 36147881983 12s 2026-09-25T14:30:03Z
completed success Quieter claim underlines, front page screenshot Front page main push 36145136428 40s 2026-09-25T14:05:12Z
completed success print Front page main repository_dispatch 36144830466 56s 2026-09-25T14:02:24Z
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && sed -i '/__engine = engine/d' src/App.tsx && grep -c __engine src/App.tsx; python - <<'EOF'
p='src/Board.tsx'; s=open(p,encoding='utf-8').read()
old=""" {story?.edition ? `Edition ${story.edition}` : (story?.section ?? 'No section')} · {ago(run.lastChangedAt)}"""
new=""" {[story?.edition && `Edition ${story.edition}`, story?.section, ago(run.lastChangedAt)].filter(Boolean).join(' · ')}"""
assert old in s; s=s.replace(old,new); open(p,'w',encoding='utf-8').write(s)
EOF
npx tsc --noEmit -p . && rm -f tsconfig.tsbuildinfo && npm run build 2>&1 | tail -1 && npm run deploy -- --yes 2>&1 | tail -1
0
[32m✔[39m Build Sanity application (638ms)
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs 'https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os' nd8.png "(() => { const li = [...document.querySelectorAll('.coverage li')].find(l => l.textContent.includes('Marcellina')); li.querySelector('button').click(); return 'clicked' })()" "(async () => { await new Promise(r => setTimeout(r, 6000)); return (document.querySelector('.lower .run')?.innerText.slice(0,150) || 'NO PANEL') + ' || fail=' + (document.querySelector('.failure')?.textContent || '') })()"
> "clicked"
> "REPORTING\nDrafts filed: 1 · Open in Studio\n×\nMarcellina Triarius\n\nWrite it from the records: active\n\nReporting just now || fail="
banner hidden: 1
The panel now opens on the pitch. Running the reporter.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && timeout 400 npm run desk -- --once 2>&1 | tail -5
[effect.draft-story] filed "Marcellina Triarius and Charites Decrescendo: Who Can't Stop Talking?" after 17 tool calls
[effect.fact-check] passed: 7 claims, 0 problems
production.wf-instance.397d5249ad26 draft-story done (122s)
production.wf-instance.397d5249ad26 fact-check done (122s)
$ set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'drafts'});
c.fetch('*[_id==\"story-marcellina-triarius\"][0]{headline,dek,body[]{\"t\":children[]{text,marks},markDefs[]{_key,_type,\"r\":records[]->{colonyDay,hour,text}}}}').then(d=>{console.log('H:',d.headline);console.log('D:',d.dek);for(const b of d.body){for(const s of b.t){const m=b.markDefs.find(m=>s.marks?.includes(m._key));console.log((m?m._type:'plain').padEnd(6),'|',s.text);if(m?.r)console.log(' ->',m.r.length+' recs:',m.r.slice(0,12).map(r=>r.colonyDay+'/'+r.hour+' '+r.text).join('; '))}console.log()}})"; python /c/tmp/q.py '*[_type=="pawn" && name match "Marcellina*" || name match "Charites*"]{name,gender,age,relationNote,everColonist}'
H: Marcellina Triarius and Charites Decrescendo: Who Can't Stop Talking?
D: The new face in the records spent Day 305 chatting, getting insulted, and chatting some more
aside | Until now, this paper hasn't printed a word about a very chatty newcomer to the records. Time to catch up.
claim | Marcellina Triarius turns up late on Day 304, chatting with Aquila Summanus, Reiraborvas 'Grasshopper' Canga and Charites Decrescendo.
-> 3 recs: 304/22 Aquila Summanus chatted with Marcellina Triarius.; 304/22 Reiraborvas 'Grasshopper' Canga chatted with Marcellina Triarius.; 304/22 Marcellina Triarius chatted with Charites Decrescendo.
aside | Where has she been all this time? The records don't say.
claim | The big story is Charites Decrescendo, who turns up in 12 of her chats across Days 304 and 305.
-> 12 recs: 304/22 Marcellina Triarius chatted with Charites Decrescendo.; 304/23 Charites Decrescendo chatted with Marcellina Triarius.; 305/0 Charites Decrescendo chatted with Marcellina Triarius.; 305/0 Charites Decrescendo chatted with Marcellina Triarius.; 305/1 Marcellina Triarius chatted with Charites Decrescendo.; 305/3 Charites Decrescendo chatted with Marcellina Triarius.; 305/4 Charites Decrescendo chatted with Marcellina Triarius.; 305/18 Charites Decrescendo chatted with Marcellina Triarius.; 305/20 Charites Decrescendo chatted with Marcellina Triarius.; 305/21 Marcellina Triarius chatted with Charites Decrescendo.; 305/21 Marcellina Triarius chatted with Charites Decrescendo.; 305/22 Marcellina Triarius chatted with Charites Decrescendo.
claim | Late on Day 305, Marcellina chatted with Charites three times in a row.
-> 3 recs: 305/21 Marcellina Triarius chatted with Charites Decrescendo.; 305/21 Marcellina Triarius chatted with Charites Decrescendo.; 305/22 Marcellina Triarius chatted with Charites Decrescendo.
aside | What were they talking about? We'd give a lot to know.
claim | Not everyone was so friendly. Early on Day 305, Aquila Summanus insulted Marcellina, and within the hour Charango Paphianne did too.
-> 2 recs: 305/0 Aquila Summanus insulted Marcellina Triarius.; 305/1 Charango Paphianne insulted Marcellina Triarius.
claim | Only a few hours later that same day, Marcellina was chatting with Charango, and Aquila was chatting with her again.
-> 2 recs: 305/3 Marcellina Triarius chatted with Charango Paphianne.; 305/4 Aquila Summanus chatted with Marcellina Triarius.
aside | Short memories or
[... 691 more characters trimmed]
$ python /c/tmp/q.py '*[_type=="pawn" && name=="Marcellina Triarius"][0]{..., "n":count(*[_type=="record" && references(^._id)]), "first":*[_type=="record" && references(^._id)]|order(tick asc)[0]{colonyDay,hour,text}, "kinds":array::unique(*[_type=="record" && references(^._id)].kind), "late":*[_type=="record" && references(^._id) && colonyDay==305 && hour>=21]|order(tick asc){hour,text}}'; grep -n -i "pronoun\|gender" ../BUILDLOG.md | head
{"_createdAt": "2026-09-25T13:15:35Z", "_id": "pawn-Human113017", "_rev": "MpdCHrJeN1WypWW7HH1iE1", "_type": "pawn", "_updatedAt": "2026-09-25T13:15:35Z", "aliases": ["Marcellina", "Marcellina Triarius", "Triarius"], "first": {"colonyDay": 304, "hour": 22, "text": "Aquila Summanus chatted with Marcellina Triarius."}, "formerNames": [], "kinds": ["talk"], "late": [{"hour": 21, "text": "Marcellina Triarius chatted with Charites Decrescendo."}, {"hour": 21, "text": "Marcellina Triarius chatted with Charites Decrescendo."}, {"hour": 22, "text": "Marcellina Triarius chatted with Charites Decrescendo."}, {"hour": 22, "text": "Iola 'Smalls' Schaefer chatted with Marcellina Triarius."}, {"hour": 23, "text": "Marcellina Triarius chatted with Aquila Summanus."}], "n": 29, "name": "Marcellina Triarius", "shortName": "Marcellina", "thingId": "Human113017"}
46:None failed the checker, because the reporter runs `check_draft` and fixes things before filing. I still read each one against its records by hand, and checked the counts myself: the 39 berry reports, the five on Day 289, the five wounds on Day 117. They were right. I also checked the pronouns, which the checker doesn't look at.
$ cd /d/Repos/web-apps/estian-tattler && grep -n -i -B2 -A12 "spike" newsroom/workflows/*.ts 2>/dev/null | head -60; ls newsroom | head -40
95- defineTransition({
96- name: 'kill',
97: title: 'Spiked by the fact-checker',
98: to: 'spiked',
99- when: `${checked} && !$effects['fact-check'].passed && $fields.drafts >= ${MAX_DRAFTS}`,
100- }),
101- ],
102- }),
103- defineStage({
104- name: 'editor',
105- title: "Editor's desk",
106- fields: [defineField({type: 'string', name: 'decision', title: 'Decision'})],
107- activities: [
108- defineActivity({
109- name: 'review',
110- title: 'Read it and decide',
--
128- }),
129- defineAction({
130: name: 'spike',
131: title: 'Spike it',
132- status: 'done',
133: ops: [{type: 'field.set', target: {field: 'decision'}, value: {type: 'literal', value: 'spike'}}],
134- }),
135- ],
136- }),
137- ],
138- transitions: [
139- defineTransition({name: 'to-press', to: 'printing', when: "$allActivitiesDone && $fields.decision == 'print'"}),
140- defineTransition({name: 'rewrite', to: 'reporting', when: "$allActivitiesDone && $fields.decision == 'rewrite'"}),
141: defineTransition({name: 'spike', to: 'spiked', when: "$allActivitiesDone && $fields.decision == 'spike'"}),
142- ],
143- }),
144- defineStage({
145- name: 'printing',
146- title: 'On the press',
147- activities: [
148- defineActivity({
149- name: 'press',
150- title: 'Print and rebuild the front page',
151- actions: [
152- defineAction({
153- name: 'run',
--
169- }),
170- defineStage({name: 'printed', title: 'Printed'}),
171: defineStage({name: 'spiked', title: 'Spiked'}),
172- ],
173-})
desk-runner.ts
effects
lib
node_modules
package-lock.json
package.json
sanity.workflow.ts
test
tsconfig.json
workflows
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" D:/Repos/web-apps/estian-tattler/docs/nightdesk.png "new Promise(r=>{const t=Date.now();(function w(){const b=[...document.querySelectorAll('button')].find(x=>/Marcellina/.test(x.textContent));if(b){b.click();setTimeout(()=>r('clicked '+b.textContent.slice(0,80)),8000)}else if(Date.now()-t>40000)r('no card');else setTimeout(w,1000)})()})" "[...document.querySelectorAll('.run button')].map(b=>b.textContent+(b.disabled?'(off)':''))"
> "clicked 2m agoMarcellina Triarius and Charites Decrescendo: Who Can't Stop Talking?"
> ["×","Send to press","Send back(off)","Spike it"]
banner hidden: 1
D:\Repos\web-apps\estian-tattler\docs\nightdesk.png
[image removed — image/png, stripped by compact-transcripts]
$ sed -i "s/height: 950,/height: Number(process.env.H || 950),/" studio_do.mjs && grep -n "height" studio_do.mjs && H=1400 FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" D:/Repos/web-apps/estian-tattler/docs/nightdesk.png "new Promise(r=>{const t=Date.now();(function w(){const b=[...document.querySelectorAll('button')].find(x=>/Marcellina/.test(x.textContent));if(b){b.click();setTimeout(()=>r('clicked'),8000)}else if(Date.now()-t>40000)r('no card');else setTimeout(w,1000)})()})" "(()=>{const b=[...document.querySelectorAll('.run button')].find(x=>/Spike/.test(x.textContent));b.scrollIntoView({block:'end'});return document.scrollingElement.scrollTop+' '+innerHeight})()"
11: await c.send('Emulation.setDeviceMetricsOverride', { width: 1500, height: Number(process.env.H || 950), deviceScaleFactor: 1, mobile: false });
> "clicked"
> "0 1384"
banner hidden: 1
D:\Repos\web-apps\estian-tattler\docs\nightdesk.png
[image removed — image/png, stripped by compact-transcripts]
That's the README shot. Now I'm spiking it from the app, which tests the one editor path we haven't used yet.
$ FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" - "new Promise(r=>{const t=Date.now();(function w(){const b=[...document.querySelectorAll('button')].find(x=>/Marcellina/.test(x.textContent));if(b){b.click();setTimeout(()=>r('clicked'),6000)}else if(Date.now()-t>40000)r('no card');else setTimeout(w,1000)})()})" "(()=>{const b=[...document.querySelectorAll('.run button')].find(x=>/Spike/.test(x.textContent));b.click();return 'spiked click'})()" "new Promise(r=>{const t=Date.now();(function w(){const h=[...document.querySelectorAll('h3,h2,div')].map(x=>x.textContent).find(s=>/^SPIKED\s*1/i.test(s.trim()));if(h||Date.now()-t>45000)r((h||'timeout')+' | '+document.querySelector('.run')?.innerText.slice(0,200));else setTimeout(w,1500)})()})"
> "clicked"
> "spiked click"
> "Spiked 1 | EDITOR'S DESK\nDrafts filed: 1 · Open in Studio\n×\nMarcellina Triarius and Charites Decrescendo: Who Can't Stop Talking?\n\nThe new face in the records spent Day 305 chatting, getting insulted, and chatti"
banner hidden: 1
$ cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows show production.wf-instance.397d5249ad26 --tag production 2>&1 | head -40; python /c/tmp/q.py 'count(*[_id in ["story-marcellina-triarius","drafts.story-marcellina-triarius"]])'
story-desk v1 production.wf-instance.397d5249ad26
Stage: spiked
Started: 2026-09-25 22:31:01
Completed: 2026-09-25 22:38:03
Tag: production
Stages:
• reporting (exited 2026-09-25 22:33:34)
[32m✔[39m report [done]
• fact-check (exited 2026-09-25 22:33:45)
[32m✔[39m check [done]
• editor (exited 2026-09-25 22:38:03)
[32m✔[39m review [done]
• spiked (current)
0
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && cat src/Board.tsx | head -40; grep -rn "status\|completedAt\|currentStage" node_modules/@sanity/workflow-engine/dist/*.d.ts 2>/dev/null | grep -i "interface WorkflowInstance\|currentStage\|abort" | head
import {useQuery} from '@sanity/sdk-react'
import {useWorkflowInstances} from '@sanity/workflow-sdk'
import type {Engine} from '@sanity/workflow-engine'
import {WORKFLOW, ago, bareId, fieldOf, stagesOf, subjectOf} from './desk'
interface StoryRow {
_id: string
headline?: string
section?: string
edition?: number
}
/** Every story-desk run, one column per stage, live. */
export function Board({engine, open, onOpen}: {engine: Engine; open?: string; onOpen: (id: string) => void}) {
const {instances, loading, error} = useWorkflowInstances({engine, filter: {definition: WORKFLOW, includeCompleted: true}})
const {data: stories} = useQuery<StoryRow[]>({query: '*[_type == "story"]{_id, headline, section, edition}'})
const headlines = new Map(stories.map((s) => [bareId(s._id), s]))
if (error) return <p className="quiet">The run list failed to load: {String(error)}</p>
if (loading || !instances) return <p className="quiet">Pulling the runs…</p>
if (!instances.length) return <p className="quiet">Nothing on the desk. Pitch a colonist below.</p>
const stages = stagesOf(instances[0])
const runs = instances.filter((r) => !r.abortedAt).sort((a, b) => b.lastChangedAt.localeCompare(a.lastChangedAt))
return (
<div className="board">
{stages.map((stage) => {
const here = runs.filter((r) => r.currentStage === stage.name)
return (
<section key={stage.name} className={`column stage-${stage.name}`}>
<h2>
{stage.title} <span className="count">{here.length}</span>
</h2>
{here.map((run) => {
const story = headlines.get(subjectOf(run) ?? '')
const note = fieldOf<string>(run, 'deskNote')
return (
<button key={run._id} className={`card${run._id === open ? ' open' : ''}`} onClick={() => onOpen(run._id)}>
<span className="kicker">
node_modules/@sanity/workflow-engine/dist/define.d.ts:1435: * `completedAt` (ISO timestamp or null). Completed and aborted runs are
node_modules/@sanity/workflow-engine/dist/index.d.ts:778: "currentStage" | "stages"
node_modules/@sanity/workflow-engine/dist/index.d.ts:2568: * remaining path to delete them. The gate is `abortedAt`, not `completedAt` —
node_modules/@sanity/workflow-engine/dist/index.d.ts:2753: | "currentStage"
node_modules/@sanity/workflow-engine/dist/index.d.ts:3065: "currentStage" | "effectHistory" | "p
[... 596 more characters trimmed]
$ python - <<'EOF'
p='src/Coverage.tsx';s=open(p,encoding='utf8').read()
rep=[
("import {useCreateDocument, useQuery} from '@sanity/sdk-react'\n","import {useCreateDocument, useQuery} from '@sanity/sdk-react'\nimport {useWorkflowInstances} from '@sanity/workflow-sdk'\n"),
("import {TAG, WORKFLOW, storyGdr} from './desk'","import {TAG, WORKFLOW, bareId, storyGdr, subjectOf} from './desk'"),
(" onDesk: string[]\n}"," unprinted: {_id: string; leads: string[] | null}[]\n}"),
(' "onDesk": *[_type == "story" && !defined(printedAt)].leads[]._ref\n',' "unprinted": *[_type == "story" && !defined(printedAt)]{_id, "leads": leads[]._ref}\n'),
(" const onDesk = new Set(data.onDesk)\n",
" // Only stories with a live run count as on the desk; a spiked story shouldn't lock its leads out of a pitch.\n const {instances} = useWorkflowInstances({engine, filter: {definition: WORKFLOW}})\n const live = new Set(instances?.map(subjectOf))\n const onDesk = new Set(data.unprinted.filter((s) => live.has(bareId(s._id))).flatMap((s) => s.leads ?? []))\n"),
]
for a,b in rep:
assert a in s,a; s=s.replace(a,b)
open(p,'w',encoding='utf8').write(s)
EOF
grep -n "abortedAt\|includeCompleted" node_modules/@sanity/workflow-sdk/dist/*.d.ts | head -8; npx tsc --noEmit -p . && echo TSC OK
TSC OK
$ grep -rn "includeCompleted" node_modules/@sanity/workflow-sdk/dist/ node_modules/@sanity/workflow-engine/dist/index.d.ts | head -5
node_modules/@sanity/workflow-engine/dist/index.d.ts:5505: * list surface's `includeCompleted` filter negates (the engine stamps
node_modules/@sanity/workflow-engine/dist/index.d.ts:5857: includeCompleted?: boolean;
$ sed -n 5500,5512p node_modules/@sanity/workflow-engine/dist/index.d.ts; sed -n 5850,5860p node_modules/@sanity/workflow-engine/dist/index.d.ts
identity: AssignmentIdentity,
): boolean;
/**
* The in-flight arm — the one spelling of "not completed/aborted" every
* list surface's `includeCompleted` filter negates (the engine stamps
* `completedAt` on entry into any terminal stage, aborts included). A
* sibling of {@link tagScopeFilter} for list builders that compose their
* own conditions.
*/
export declare function inFlightFilter(): string;
/** Why one caller-supplied initial-field row cannot be consumed. */
* activity; `held` matches the direct user and shadows every role. */
assignment?: {
userId: string;
roles?: readonly string[] | undefined;
states?: readonly AssignmentState[] | undefined;
};
/** Include completed/aborted instances (default: in-flight only). */
includeCompleted?: boolean;
/**
* Cap the read to the newest `limit` instances — the query flips to
* `startedAt desc` and slices, so a bounded consumer (a dashboard over an
The fix type-checks. The engine's default filter is in-flight runs only, which is exactly right. Deploying the app now and checking the coverage list.
$ npm run build 2>&1 | tail -3 && npm run deploy -- --yes 2>&1 | tail -4
[32m✔[39m Clean output folder (3ms)
- Building Sanity application
[32m✔[39m Build Sanity application (647ms)
[32m✔[39m Deploying...
Success! Application deployed to https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os — "Night Desk"
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" - "new Promise(r=>{const t=Date.now();(function w(){const li=[...document.querySelectorAll('.coverage li')];if(li.length&&document.querySelector('.board')||Date.now()-t>40000){setTimeout(()=>r(li.map(l=>l.innerText.replace(/\s+/g,' ')).join(' / ')+' || '+[...document.querySelectorAll('.board h2')].map(h=>h.innerText.replace(/\s+/g,' ')).join(', ')),5000)}else setTimeout(w,1000)})()})"
> "Reiraborvas 'Grasshopper' Canga 26/136 / Hyena Embaixada 16/114 / Snake Rato 25/113 / Rocaniraalbo Ambmea 21/91 / Flubber Flubber 10/10 / Burger Unkown 0/6 Pitch / Estian Hough 0/3 Pitch / Charites Decrescendo 1/30 / Marcellina Triarius 0/29 Pitch / Aquila Summanus 0/24 Pitch / Charango Paphianne 0/18 Pitch / Iola 'Smalls' Schaefer 0/14 Pitch / Black Hawk 0/9 Pitch || REPORTING 0, FACT-CHECK 0, EDITOR'S DESK 0, ON THE PRESS 0, PRINTED 5, SPIKED 1"
banner hidden: 1
Fixed: the spiked story's people can be pitched again, and the board reads Printed 5, Spiked 1. Next I'm checking that edition 5 is live on the Pages site, then writing up the BUILDLOG.
$ curl -s https://booyaka101.github.io/estian-tattler/ | grep -o "Flubber Flubber's Grave[^<]*\|Edition [0-9]*" | sort | uniq -c | head; cd /d/Repos/web-apps/estian-tattler && git status --short && grep -n "^## \|^### " BUILDLOG.md
2 Edition 5
1 Flubber Flubber's Grave Draws a Faithful Few\"}],[\"$\",\"p\",null,{\"className\":\"dek\",\"children\":\"Rocaniraalbo Ambmea keeps coming back, and a handful of others do too\"}],[\"$\",\"span\",null,{\"className\":\"more\",\"children\":[7,\" claims, all sourced. Read it.\"]}]]}],[\"$\",\"section\",null,{\"className\":\"columns\",\"children\":[[\"$\",\"$Ld\",\"story-restaurant-reviews\",{\"href\":\"/story/restaurant-reviews/\",\"className\":\"card\",\"children\":[[\"$\",\"span\",null,{\"className\":\"section\",\"children\":\"Commerce\"}],[\"$\",\"h2\",null,{\"children\":\"Eastern Republic sours on the restaurant: \\\"Score: -12\\\"\"}],[\"$\",\"p\",null,{\"className\":\"dek\",\"children\":\"From a perfect 100 to a frosty farewell, the colony's dining trade has had a bumpy season.\"}]]}],[\"$\",\"$Ld\",\"story-fist-fights\",{\"href\":\"/story/fist-fights/\",\"className\":\"card\",\"children\":[[\"$\",\"span\",null,{\"className\":\"section\",\"children\":\"Brawls\"}],[\"$\",\"h2\",null,{\"children\":\"Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo\"}],[\"$\",\"p\",null,{\"className\":\"dek\",\"children\":\"Hyena Embaixada twice left Grasshopper wounded, then talked her down on Day 265, the day Snake Rato traded blows with Rocaniraalbo Ambmea.\"}]]}],[\"$\",\"$Ld\",\"story-berry-rot\",{\"href\":\"/story/berry-rot/\",\"className\":\"card\",\"children\":[[\"$\",\"span\",null,{\"className\":\"section\",\"children\":\"Front page\"}],[\"$\",\"h2\",null,{\"children\":\"The berry pile keeps rotting\"}],[\"$\",\"p\",null,{\"className\":\"dek\",\"children\":\"From Day 264 to Day 296, storage kept reporting berries rotted away, while berry bushes died left unharvested\"}]]}],[\"$\",\"$Ld\",\"story-snake-grasshopper\",{\"href\":\"/story/snake-grasshopper/\",\"className\":\"card\",\"children\":[[\"$\",\"span\",null,{\"className\":\"section\",\"children\":\"Hearts\"}],[\"$\",\"h2\",null,{\"children\":\"Snake and Grasshopper: the marriage, the split, the binges\"}],[\"$\",\"p\",null,{\"className\":\"dek\",\"children\":\"Wed on Day 132, split on Day 252, and chatting again on Day 305\"}]]}]]}],[\"$\",\"footer\",null,{\"className\":\"colophon\",\"children\":[[\"$\",\"h3\",null,{\"children\":\"How this paper works\"}],[\"$\",\"p\",null,{\"children\":[\"The newsroom is a Sanity dataset of \",631,\" records pulled out of one RimWorld save: tales, letters, messages and pawn-to-pawn talk. The save forgets anything older than day \",117,\",
[... 776 more characters trimmed]
D:\Repos\web-apps\estian-tattler\BUILDLOG.md
1 # Build log
2
3 This was built by Claude Code (Claude Opus 5.5) in one session on 2026-09-25. The owner of this repo handed over the whole challenge with one instruction, to go all out. They didn't pick the idea or write any of it. So "I" below is Claude Code. The prompts that mattered were the ones I wrote for the reporter, and they're quoted here.
4
5 ## Picking the idea
6
7 My first idea overlapped with a project the owner had already shut down, so I dropped it.
8
9 The owner plays heavily modded RimWorld, and a save file is a strange thing to build on. It keeps tales ("X was wounded by Y", "X married Y"), the letters the game shows you, a message log, and the last few dozen pawn-to-pawn conversations. The Tribe of Estian save had 354 tales and a couple of hundred messages. That's a colony's gossip with timestamps. A tabloid is the obvious format for gossip, and the thing that makes a tabloid interesting to build is making it unable to lie.
10
11 ## Getting the save into Sanity
12
13 `ingest/parse_save.py` walks the save XML. What went wrong:
14
15 - The def-label lookup scanned every mod folder for the human-readable names of things and took about three and a half minutes. It now caches the labels in `ingest/def_labels.json` and only rescans with `--scan-defs`.
16 - Play-log conversations came out empty. The pawn lookup only walked `<li>` elements, but pawns on the map are `<thing>` elements. Switched to `root.iter()`.
17 - Text still had the game's colour and faction tags in it, like `(*Name)...(/Name)`. Generalised the regex that strips them.
18 - Pawn names came from the first snapshot of a pawn in the save, not the latest, so a pawn could show a name they no longer went by. It now takes the latest.
19 - Sanity treats document ids with a dot in them as non-public, and the front page reads the public API with no token. Ids are hyphenated: `record-tale-547`, `pawn-Human147640`.
20
21 The save forgets tales older than roughly day 117, so the colony document records `firstRecordDay` and the front page says so. Nothing before it can be printed.
22
23 ## The schema
24
25 A story's body is Portable Text with two annotations, `claim` (references to records, at least one) and `aside` (the paper's voice). That's what makes the checker possible. The claim isn't free text next to a list of sources, it's the exact span of words tied to the exact records.
26
27 I wrote the checker (`studio/lib/factcheck.ts`) before the reporter, with tests, so the re
[... 5721 more characters trimmed]
$ git diff README.md; cat nightdesk/.gitignore 2>/dev/null; cat .gitignore; ls nightdesk
warning: in the working copy of 'README.md', CRLF will be replaced by LF the next time Git touches it
diff --git a/README.md b/README.md
index 20fe1d3..98207dd 100644
--- a/README.md
+++ b/README.md
@@ -35,13 +35,24 @@ reporting -> fact-check -> editor -> printing -> printed
- **editor** waits for a person: Send to press, Send back (with a note the reporter gets on the next draft), or Spike it. This happens in the Studio's Workflows tool or the story's Workflows tab.
- **printing** runs the `print` effect: numbers the edition, publishes the draft, and fires a `repository_dispatch` that rebuilds the front page.
-Stories get pitched from the Studio with the "Pitch a story" action on any record.
+Stories get pitched from the Studio with the "Pitch a story" action on any record, or from the Night Desk.
+
+## The Night Desk
+
+`nightdesk/` is an App SDK app that runs in the Sanity Dashboard. It's the editor's view of the whole paper rather than one story:
+
+- A board with every run in its current stage, live from `useWorkflowInstances`.
+- A run panel driven by `useWorkflowSession`. It shows the story with every claim marked with how many records back it, and the editor's buttons come from the session's evaluation, so Send back asks for its note because the action declares that param.
+- A coverage meter: how many of each colonist's records the printed stories cite. Anyone at zero gets a Pitch button, which creates the story with their records as leads and starts a `story-desk` run for it.
+
+Dashboard apps are only visible to members of the organization, so `docs/nightdesk.png` is what it looks like.
## What's here
- `ingest/parse_save.py` reads a `.rws` save (plain or gzipped) and writes `data/tattler.ndjson`: the colony, its pawns and every record. Tales carry no text of their own in the save, so it renders them from the tale def and the pawns involved.
- `studio/` is the schema (colony, pawn, record, story), the Receipts view that lays each sentence next to its records, the Pitch action, and the workflow plugin.
- `newsroom/` is the workflow definition and the effect handlers, plus `desk-runner.ts`, which claims and runs pending effects.
+- `nightdesk/` is the App SDK dashboard app described above.
- `frontpage/` is a static Next.js site. Every underlined claim shows its records on hover or focus.
## Running it
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
/node_modules
/.pnp
.pnp.js
[... 500 more characters trimmed]
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && head -20 README.md; wc -l README.md; cat package.json | head -20
# Sanity App
A custom application built with the [Sanity App SDK](https://www.sanity.io/docs/app-sdk?utm_source=readme). It is a React app that runs inside your organization's Sanity Dashboard, in development and when deployed.
## Commands
- `npm run dev` starts the dev server and prints a Sanity Dashboard URL where your app runs. Open it and sign in with your Sanity account.
- `npm run build` builds the app for production.
- `npm run deploy` deploys the app to the Sanity Dashboard.
## Configuration
- `src/App.tsx` is the app entry point. The `SanityApp` config sets which project and dataset the app reads content from.
- `sanity.cli.ts` holds your organization ID and the app entry path.
## Learn more
- [App SDK Quickstart Guide](https://www.sanity.io/docs/app-sdk/sdk-quickstart?utm_source=readme)
- [App SDK documentation](https://www.sanity.io/docs/app-sdk?utm_source=readme)
- [API reference](https://reference.sanity.io/_sanity/sdk-react/)
22 README.md
{
"name": "nightdesk",
"private": true,
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"build": "sanity build",
"deploy": "sanity deploy",
"dev": "sanity dev",
"start": "sanity start"
},
"keywords": [
"sanity"
],
"dependencies": {
"@sanity/sdk": "^3.5.0",
"@sanity/sdk-react": "^3.5.0",
"@sanity/workflow-components": "^0.35.0",
"@sanity/workflow-engine": "^0.35.0",
$ cd /d/Repos/web-apps/estian-tattler && ls studio frontpage | grep -i readme; git ls-files | grep -i readme
README.md
$ rm nightdesk/README.md && sed -n '/## Running it/,$p' README.md
## Running it
Node 22. The dataset (`lcvgtfvq/production`) is public, so the front page builds with no token:
```sh
cd frontpage && npm install && npm run build
```
The newsroom needs a Sanity token with write access in `newsroom/.env` as `SANITY_TOKEN`, and a Claude login (Claude Code) or `ANTHROPIC_API_KEY` for the reporter. `GITHUB_TOKEN` is optional; without it the press publishes but doesn't rebuild the site.
```sh
cd newsroom && npm install
npm test
npx sanity-workflows start story-desk --field 'subject={"id":"dataset:lcvgtfvq:production:story-berry-rot","type":"story"}'
npm run desk
```
To rebuild the dataset from your own save:
```sh
python ingest/parse_save.py "path/to/Colony.rws" --colony "Your Colony"
```
[BUILDLOG.md](BUILDLOG.md) is how this got built, including what broke.
D:\Repos\web-apps\estian-tattler\README.md
The file D:\Repos\web-apps\estian-tattler\README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Writing the BUILDLOG section on the Night Desk now. First I'm pulling one detail from the earlier session so I don't misstate it.
$ cd /c/Users/owner/.claude/projects/C--Users-owner && python - <<'EOF'
import json,re
for line in open('cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl',encoding='utf8'):
try: d=json.loads(line)
except: continue
m=d.get('message',{}); c=m.get('content')
if isinstance(c,list):
for b in c:
t=b.get('text') or b.get('thinking') or ''
if isinstance(t,str) and re.search(r'Rocaniraalbo',t) and re.search(r'pronoun|\bhe\b|\bshe\b|gender',t,re.I):
for mm in re.finditer(r'[^.\n]*(?:pronoun|gender)[^.\n]*',t,re.I): print('-',mm.group(0)[:300])
EOF
(Bash completed with no output)
$ ls -la cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl && grep -o '[^"]\{0,200\}pronoun[^"]\{0,200\}' cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl | grep -v "checked the pronouns, which" | tail -8
-rw-r--r-- 1 owner 197609 18724957 Sep 25 22:41 cb5e482e-d893-4cd2-9e25-7f331f362c4c.jsonl
('content')\n if isinstance(c,list):\n for b in c:\n t=b.get('text') or b.get('thinking') or ''\n if isinstance(t,str) and re.search(r'Rocaniraalbo',t) and re.search(r'pronoun|\\bhe\\b|\\bshe\\b|gender',t,re.I):\n for mm in re.finditer(r'[^.\\n]*(?:pronoun|gender)[^.\\n]*',t,re.I): print('-',mm.group(0)[:300])\nEOF
Find the Rocaniraalbo pronoun check details
('content')\n if isinstance(c,list):\n for b in c:\n t=b.get('text') or b.get('thinking') or ''\n if isinstance(t,str) and re.search(r'Rocaniraalbo',t) and re.search(r'pronoun|\\bhe\\b|\\bshe\\b|gender',t,re.I):\n for mm in re.finditer(r'[^.\\n]*(?:pronoun|gender)[^.\\n]*',t,re.I): print('-',mm.group(0)[:300])\nEOF
Find the Rocaniraalbo pronoun check details
]\\{0,200\\}pronoun[^\
Grep transcript for pronoun mentions
]\\{0,200\\}pronoun[^\
Grep transcript for pronoun mentions
The earlier Rocaniraalbo detail isn't recoverable, so I'll leave it out rather than guess. I'm checking the printed Flubber story's pronouns directly instead.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && python /c/tmp/q.py '*[_id=="story-flubber-flubber"][0]{headline,edition,"text":pt::text(body)}'
{
"edition": 5,
"headline": "Flubber Flubber's Grave Draws a Faithful Few",
"text": "The Tattler has never printed a word about Flubber Flubber, but the colony's grave-visiting logs have been keeping his memory for us: ten visits to his grave, from Day 123 to Day 269. Who was he, and how did he end up there? The files we pulled don't say.\n\nThe first recorded mourner was Hyena Embaixada, who visited the grave on Day 123.\n\nThen came the regular. Rocaniraalbo Ambmea visited the grave on Day 154, Day 192, Day 213, Day 230 and Day 235 � five visits in all. Of all the logged visits, Rocaniraalbo Ambmea made more than any other visitor on record. That's devotion, or at least a very well-worn path.\n\nIn between, Snake Rato stopped by on Day 232. Hyena Embaixada came back on Day 256, the second visit on record from the grave's first mourner.\n\nThe newest face at the graveside is Reiraborvas 'Grasshopper' Canga, who visited on Day 259 and again on Day 269. And since then? Nothing in the logs we pulled � has the path gone quiet, or is someone still paying respects off the books?\n\nA faithful few, a lonely grave, and a story the records barely tell. If you knew the man, the Tattler would love to hear from you."
}
The printed edition 5 calls Flubber "he" and "the man". I'm checking whether anything on file actually supports that, since the pawn documents carry no gender.
$ python /c/tmp/q.py '*[_type=="pawn" && name match "Flubber"][0]{..., "recs": *[_type=="record" && references(^._id)]{kind,colonyDay,text}}'; ls ../ingest ../data 2>/dev/null; grep -n -i "gender" ../ingest/parse_save.py | head
{"_createdAt": "2026-09-25T13:15:35Z", "_id": "pawn-Human99323", "_rev": "MpdCHrJeN1WypWW7HH1iE1", "_type": "pawn", "_updatedAt": "2026-09-25T13:15:35Z", "age": 24, "aliases": ["Flubber", "Flubber Flubber"], "everColonist": true, "formerNames": [], "gender": "Male", "name": "Flubber Flubber", "recs": [{"colonyDay": 256, "kind": "tale", "text": "Hyena Embaixada visited the grave of Flubber Flubber."}, {"colonyDay": 259, "kind": "tale", "text": "Reiraborvas 'Grasshopper' Canga visited the grave of Flubber Flubber."}, {"colonyDay": 269, "kind": "tale", "text": "Reiraborvas 'Grasshopper' Canga visited the grave of Flubber Flubber."}, {"colonyDay": 123, "kind": "tale", "text": "Hyena Embaixada visited the grave of Flubber Flubber."}, {"colonyDay": 154, "kind": "tale", "text": "Rocaniraalbo Ambmea visited the grave of Flubber Flubber."}, {"colonyDay": 192, "kind": "tale", "text": "Rocaniraalbo Ambmea visited the grave of Flubber Flubber."}, {"colonyDay": 213, "kind": "tale", "text": "Rocaniraalbo Ambmea visited the grave of Flubber Flubber."}, {"colonyDay": 230, "kind": "tale", "text": "Rocaniraalbo Ambmea visited the grave of Flubber Flubber."}, {"colonyDay": 232, "kind": "tale", "text": "Snake Rato visited the grave of Flubber Flubber."}, {"colonyDay": 235, "kind": "tale", "text": "Rocaniraalbo Ambmea visited the grave of Flubber Flubber."}], "relationNote": null, "shortName": "Flubber", "thingId": "Human99323"}
../data:
tattler.ndjson
../ingest:
def_labels.json
parse_save.py
183: doc.update({"gender": snap.findtext("gender"), "age": int(snap.findtext("age") or 0) or None,
$ cd /d/Repos/web-apps/estian-tattler && python - <<'EOF'
p='BUILDLOG.md'
s=open(p,encoding='utf8').read().rstrip('\n')+'\n'
s+='''
## The Night Desk
The Studio's Workflows tool shows one run at a time, and the paper's real question is who it hasn't written about. So `nightdesk/` is an App SDK app in the Dashboard: `useWorkflowInstances` for a board of every run by stage, `useWorkflowSession` for the open run, `useQuery` for the story and the coverage counts, and `useCreateDocument` plus `engine.startInstance` for Pitch. The buttons in the run panel aren't hardcoded. They're the actions the session's evaluation says are available, and Send back stays disabled until its note is filled in, because the action declares a required `note` param. The engine records each commit's execution context, and runs driven from the app show up in the instance history as `sdk` / `browser`.
What went wrong:
- Pitch created the story, awaited `startInstance`, then opened the run. Nothing appeared to happen. The run was on the board within seconds, but the promise settled much later, since every engine commit from the browser does a tick that takes about three seconds. Pitch now mints the instance id with `instanceDocId`, opens the panel on it straight away, and only uses the promise to report an error.
- The first pitched story got the id `story-human99323-<timestamp>`. I aborted that run and ids are slugged from the name now, so it's `story-flubber-flubber`.
- The coverage list flagged three people "on the desk" who had nothing pending. Grave-visit records name the visitor as well as the dead, so a pitch for one colonist puts records about the others into its leads. The flag now only shows for someone the paper hasn't cited at all.
- After I spiked a story, everyone in its leads stayed "on the desk" with no Pitch button. "On the desk" meant any unprinted story, and a spiked story is never printed. It now means a story with a run still in flight.
- The Dashboard puts the app in an iframe on a different host from the page, so the CDP screenshots attach to the iframe target by host.
Two stories went through the whole loop from the app.
Flubber Flubber had ten records, all grave visits, and the paper had never printed him. Pitch, reporter, fact-check passed with 8 claims. Reading it, three sentences said more than their receipts: "more than anyone else on record", "the last one in the files" and "the colony's own records say little more". Each named the right people and cited real visits, so the checker couldn't object, but they were claims about records the story didn't cite. I sent it back from the Night Desk with that note. The rewrite cited all ten visits, so "more than any other visitor on record" is now something a reader can count, and it passed with 7 claims. I sent it to press from the app and it's edition 5. Flubber's pawn is `Male` in the save, so "his grave" is right.
Marcellina Triarius, a visitor with 29 records, went the other way. The draft passed with 7 claims, and every one checked out against its records. I spiked it anyway. All 29 records are chats, so two days of small talk was the whole story. It called her "she", but visitors come through the ingest with no gender, so that was a guess from the name. And it ended with "the last word so far went to Aquila Summanus" on a record where Marcellina is the one talking. That's the third thing the checker can't see after absence and comparison: a claim that cites the right record and reads it backwards.
'''
open(p,'w',encoding='utf8',newline='\n').write(s)
EOF
grep -c "—" BUILDLOG.md README.md
BUILDLOG.md:0
README.md:0
$ cd /d/Repos/web-apps/estian-tattler && python - <<'EOF'
p='BUILDLOG.md';s=open(p,encoding='utf8').read()
a='- The coverage list flagged three people "on the desk" who had nothing pending. Grave-visit records'
assert a in s
s=s.replace(a,'- Once Flubber was pitched, the coverage list flagged his grave visitors "on the desk" too, though they\'d been in print for editions. Grave-visit records')
open(p,'w',encoding='utf8',newline='\n').write(s)
EOF
git add -A && git status --short && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -q -F - <<'EOF'
Add the Night Desk, an App SDK dashboard for the story desk
A board of every story-desk run by stage, a run panel whose buttons come
from the workflow session, and a coverage meter that pitches a story for
anyone the paper hasn't cited.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git log --oneline -1 && git push -q origin HEAD 2>&1 | tail -2; git status -sb | head -1
warning: in the working copy of 'README.md', CRLF will be replaced by LF the next time Git touches it
warning: in the working copy of 'nightdesk/src/Board.tsx', CRLF will be replaced by LF the next time Git touches it
warning: in the working copy of 'nightdesk/src/Coverage.tsx', CRLF will be replaced by LF the next time Git touches it
M BUILDLOG.md
M README.md
A docs/nightdesk.png
A nightdesk/.gitignore
A nightdesk/eslint.config.mjs
A nightdesk/package-lock.json
A nightdesk/package.json
A nightdesk/sanity.cli.ts
A nightdesk/src/App.css
A nightdesk/src/App.tsx
A nightdesk/src/Board.tsx
A nightdesk/src/Coverage.tsx
A nightdesk/src/RunPanel.tsx
A nightdesk/src/desk.ts
A nightdesk/tsconfig.json
3d798ec Add the Night Desk, an App SDK dashboard for the story desk
## main...origin/main
Pushed as 3d798ec. Updating the project memory, then moving on to the Path Two post draft.
The build is committed. One gap is still worth closing before the post: the reporter guessed a pronoun. I'm checking what who_is gives it.
$ cd /d/Repos/web-apps/estian-tattler/newsroom && grep -rn "gender\|who_is" --include=*.ts effects lib | grep -v node_modules | head; grep -rn "Don't invent motives" --include=*.ts . | grep -v node_modules
effects/draft-story.ts:111: tool('who_is', 'Look up a colonist or visitor by any of their names.', {name: z.string()}, async ({name}) => {
effects/draft-story.ts:119: return `${p.name} (${[p.gender, p.age && `age ${p.age}`, p.relationNote].filter(Boolean).join(', ')}). ` +
effects/draft-story.ts:162: allowedTools: ['mcp__morgue__search_records', 'mcp__morgue__who_is', 'mcp__morgue__check_draft'],
lib/desk.ts:30:export type DeskPawn = PawnInput & {name: string; shortName?: string; gender?: string; age?: number; relationNote?: string}
lib/desk.ts:51: "pawns": *[_type == "pawn" && !(_id in path("drafts.**"))]{_id, name, shortName, aliases, gender, age, relationNote}
./effects/draft-story.ts:51:Don't invent motives, feelings or events. If the records don't say why something happened, wonder about it in an aside.
$ sed -n 40,55p effects/draft-story.ts; sed -n 108,126p effects/draft-story.ts
Everything you know comes from the colony's records: tales, letters, messages and overheard conversations pulled from the save file.
The fact-checker is a program, not a person, and it checks every sentence:
- Each sentence is either a claim or an aside.
- A claim cites the ids of the records that prove it. Only say what those records say.
- Every colonist a claim names must appear in one of its cited records.
- Every number in a claim, in digits or words, must appear in a cited record's text, be a cited record's colony day, or be the count of records cited.
- An aside is the paper's own voice: a quip, a question, a raised eyebrow. Asides may not name anyone and may not contain numbers.
- The headline and dek follow the claim rules, checked against every record the body cites.
Write like a small-town tabloid that loves these people: sharp, warm, a little nosy. Dates are colony days ("on Day 142"). Short paragraphs, 150 to 350 words in all.
Don't invent motives, feelings or events. If the records don't say why something happened, wonder about it in an aside.
Use check_draft before you file, and fix everything it reports.`
function renderRecord(r: DeskRecord): string {
const head = `${r._id} | Day ${r.colonyDay}, ${r.hour}h | ${r.kind}`
return text(hits.length > 60 ? `${hits.length} matches, first 60:\n${shown}` : shown || 'No records match.')
},
),
tool('who_is', 'Look up a colonist or visitor by any of their names.', {name: z.string()}, async ({name}) => {
const found = morgue.pawns.filter((p) => p.aliases?.some((a) => a.toLowerCase().includes(name.toLowerCase())))
if (!found.length) return text(`Nobody called ${name} in the records.`)
return text(
found
.map((p) => {
const theirs = morgue.records.filter((r) => r.pawns?.some((ref) => ref._ref === p._id))
const days = theirs.map((r) => r.colonyDay ?? 0)
return `${p.name} (${[p.gender, p.age && `age ${p.age}`, p.relationNote].filter(Boolean).join(', ')}). ` +
`Also known as ${p.aliases?.join(', ')}. In ${theirs.length} records, Day ${Math.min(...days)} to Day ${Math.max(...days)}.`
})
.join('\n'),
)
}),
tool(
'check_draft',
$ python - <<'EOF'
p='effects/draft-story.ts';s=open(p,encoding='utf8').read()
a="${[p.gender, p.age && `age ${p.age}`"
assert a in s
s=s.replace(a,"${[p.gender ?? 'gender not on file', p.age && `age ${p.age}`")
a="Don't invent motives, feelings or events. If the records don't say why something happened, wonder about it in an aside."
assert a in s
s=s.replace(a,a+"\nOnly use he or she for someone who_is gives a gender for. Otherwise use their name.")
open(p,'w',encoding='utf8').write(s)
p='../BUILDLOG.md';s=open(p,encoding='utf8').read()
a="wonder about it in an aside. Use check_draft"
assert a in s
s=s.replace(a,"wonder about it in an aside. Only use he or she for someone who_is gives a gender for. Otherwise use their name. Use check_draft")
a="That's the third thing the checker can't see after absence and comparison: a claim that cites the right record and reads it backwards.\n"
assert a in s
s=s.replace(a,a+"\nThe pronoun one is fixable upstream. `who_is` now says \"gender not on file\" instead of leaving it blank, and the prompt got its last rule before check_draft: only use he or she for someone `who_is` gives a gender for.\n")
open(p,'w',encoding='utf8',newline='\n').write(s)
EOF
npx tsc --noEmit -p . && npm test 2>&1 | tail -4
# cancelled 0
# skipped 0
# todo 0
# duration_ms 684.4777
$ git -C .. -c user.name=Booyaka101 -c user.email=owner@example.com commit -qam "Tell the reporter when a pawn's gender isn't on file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" - "new Promise(r=>{const t=Date.now();(function w(){const li=[...document.querySelectorAll('.coverage li')].find(l=>/Marcellina/.test(l.textContent));const b=li&&li.querySelector('button');if(b){b.click();setTimeout(()=>r('clicked; failure='+(document.querySelector('.coverage .failure')?.textContent||'none')+' | run='+(document.querySelector('.run')?.innerText.slice(0,120)||'')),12000)}else if(Date.now()-t>40000)r('no button');else setTimeout(w,1000)})()})"
warning: in the working copy of 'newsroom/effects/draft-story.ts', CRLF will be replaced by LF the next time Git touches it
> "clicked; failure=A draft of this document already exists. Please use or discard the existing draft before creating a new one. | run=Pick a story on the board to read it and decide."
banner hidden: 1
Confirmed: "A draft of this document already exists." A new pitch after a spike should be a new story, not a reuse of the spiked one, so the id gets a suffix when the slug is taken.
$ cd /d/Repos/web-apps/estian-tattler/nightdesk && python - <<'EOF'
p='src/Coverage.tsx';s=open(p,encoding='utf8').read()
rep=[
(" unprinted: {_id: string; leads: string[] | null}[]\n}"," unprinted: {_id: string; leads: string[] | null}[]\n stories: string[]\n}"),
(' "unprinted": *[_type == "story" && !defined(printedAt)]{_id, "leads": leads[]._ref}\n',' "unprinted": *[_type == "story" && !defined(printedAt)]{_id, "leads": leads[]._ref},\n "stories": *[_type == "story"]._id\n'),
(" const storyId = `story-${pawn.name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '')}`\n",
" const slug = `story-${pawn.name.toLowerCase().replace(/[^a-z]+/g, '-').replace(/^-|-$/g, '')}`\n const taken = new Set(data.stories.map(bareId))\n let storyId = slug\n for (let n = 2; taken.has(storyId); n++) storyId = `${slug}-${n}`\n"),
]
for a,b in rep:
assert a in s,a; s=s.replace(a,b)
open(p,'w',encoding='utf8').write(s)
EOF
npx tsc --noEmit -p . && npm run build 2>&1 | tail -1 && npm run deploy -- --yes 2>&1 | tail -2
[32m✔[39m Build Sanity application (648ms)
Success! Application deployed to https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os — "Night Desk"
Updated the existing application.
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" - "new Promise(r=>{const t=Date.now();(function w(){const li=[...document.querySelectorAll('.coverage li')].find(l=>/Marcellina/.test(l.textContent));const b=li&&li.querySelector('button');if(b){b.click();setTimeout(()=>r('clicked; failure='+(document.querySelector('.coverage .failure')?.textContent||'none')+' | run='+(document.querySelector('.run')?.innerText.slice(0,160)||'')),15000)}else if(Date.now()-t>40000)r('no button');else setTimeout(w,1000)})()})"; cd /d/Repos/web-apps/estian-tattler/newsroom && set -a && . ./.env && set +a && npx sanity-workflows list --tag production 2>&1 | head -5
> "clicked; failure=none | run=REPORTING\nDrafts filed: 1 · Open in Studio\n×\nMarcellina Triarius\n\nWrite it from the records: active\n\nReporting just now"
banner hidden: 1
instance workflow tag stage status updated
production.wf-instance.5c212766f2fd story-desk production reporting in-flight 45 seconds ago
The re-pitch works: new run 5c212766f2fd, no error. Running the reporter with the new pronoun rule.
$ npm run desk -- --once 2>&1 | tail -5 && python /c/tmp/q.py '*[_type=="story" && _id match "*marcellina-triarius-2"][0]{_id,headline,dek,"t":pt::text(body)}'
[effect.draft-story] filed "Marcellina Triarius: Insulted Twice, Still Chatting" after 14 tool calls
[effect.fact-check] passed: 10 claims, 0 problems
production.wf-instance.5c212766f2fd draft-story done (98s)
production.wf-instance.5c212766f2fd fact-check done (98s)
null
$ set -a && . ./.env && set +a && node -e "
const {createClient}=require('@sanity/client');const c=createClient({projectId:'lcvgtfvq',dataset:'production',apiVersion:'2025-02-19',token:process.env.SANITY_TOKEN,useCdn:false,perspective:'drafts'});
c.fetch('*[_id==\"story-marcellina-triarius-2\"][0]{headline,dek,body[]{\"t\":children[]{text,marks},markDefs[]{_key,_type,\"r\":records[]->{colonyDay,hour,text}}}}').then(d=>{console.log('H:',d.headline);console.log('D:',d.dek);for(const b of d.body){for(const s of b.t){const m=b.markDefs.find(m=>s.marks?.includes(m._key));console.log((m?m._type:'plain').padEnd(6),'|',s.text);if(m?.r)console.log(' ->',m.r.length+':',m.r.slice(0,6).map(r=>r.colonyDay+'/'+r.hour+' '+r.text).join('; '))}console.log()}})"
H: Marcellina Triarius: Insulted Twice, Still Chatting
D: Marcellina Triarius took hits from Aquila Summanus and Charango Paphianne on Day 305, and kept on talking to both.
claim | The Tattler has never printed the name Marcellina Triarius, and on Day 304 Marcellina started turning up everywhere.
-> 1: 304/22 Aquila Summanus chatted with Marcellina Triarius.
claim | That night Marcellina chatted with Aquila Summanus, Reiraborvas 'Grasshopper' Canga, Charites Decrescendo and Iola 'Smalls' Schaefer.
-> 4: 304/22 Marcellina Triarius chatted with Aquila Summanus.; 304/22 Reiraborvas 'Grasshopper' Canga chatted with Marcellina Triarius.; 304/22 Marcellina Triarius chatted with Charites Decrescendo.; 304/23 Iola 'Smalls' Schaefer chatted with Marcellina Triarius.
aside | Then things turned sour.
claim | At midnight on Day 305, Aquila Summanus insulted Marcellina Triarius.
-> 1: 305/0 Aquila Summanus insulted Marcellina Triarius.
claim | An hour later, Charango Paphianne did the same.
-> 1: 305/1 Charango Paphianne insulted Marcellina Triarius.
aside | A sulk? Not a bit of it, going by the log.
claim | By Day 305 Marcellina was chatting with Charango Paphianne.
-> 1: 305/3 Marcellina Triarius chatted with Charango Paphianne.
claim | On Day 305 Aquila Summanus chatted with Marcellina twice in the same hour, and Marcellina chatted right back.
-> 3: 305/4 Aquila Summanus chatted with Marcellina Triarius.; 305/4 Aquila Summanus chatted with Marcellina Triarius.; 305/4 Marcellina Triarius chatted with Aquila Summanus.
claim | The last word that night went to Marcellina, chatting with Aquila Summanus again.
-> 1: 305/23 Marcellina Triarius chatted with Aquila Summanus.
aside | But the real story may lie elsewhere.
claim | Across Day 304 and Day 305, Marcellina and Charites Decrescendo traded chats twelve times.
-> 12: 304/22 Marcellina Triarius chatted with Charites Decrescendo.; 304/23 Charites Decrescendo chatted with Marcellina Triarius.; 305/0 Charites Decrescendo chatted with Marcellina Triarius.; 305/0 Charites Decrescendo chatted with Marcellina Triarius.; 305/1 Marcellina Triarius chatted with Charites Decrescendo.; 305/3 Charites Decrescendo chatted with Marcellina Triarius.
claim | Three of those came back to back on the evening of Day 305, all started by Marcellina.
-> 3: 305/21 Marcellina Triarius chatted with Charites Decrescendo.; 305/21 Marcellina Triarius chat
[... 545 more characters trimmed]
$ cd /c/tmp/demo-shots && FRAME='vm0w2ew6j526' node studio_do.mjs "https://www.sanity.io/@oj47h4o89/application/c7c1x4pxf78nzfgaxd3ql5os" - "new Promise(r=>{const t=Date.now();(function w(){const b=[...document.querySelectorAll('.card')].find(x=>/Insulted Twice/.test(x.textContent));if(b){b.click();setTimeout(()=>r('clicked'),6000)}else if(Date.now()-t>40000)r('no card');else setTimeout(w,1000)})()})" "(()=>{const b=[...document.querySelectorAll('.run button')].find(x=>/Send to press/.test(x.textContent));if(!b||b.disabled)return 'no button';b.click();return 'pressed'})()" "new Promise(r=>{const t=Date.now();(function w(){const s=[...document.querySelectorAll('.board h2')].map(h=>h.innerText.replace(/\s+/g,' '));if(s.some(x=>/ON THE PRESS 1/i.test(x))||Date.now()-t>45000)r(s.join(', '));else setTimeout(w,1500)})()})" && cd /d/Repos/web-apps/estian-tattler/newsroom && npm run desk -- --once 2>&1 | tail -3
> "clicked"
> "pressed"
> "REPORTING 0, FACT-CHECK 0, EDITOR'S DESK 0, ON THE PRESS 1, PRINTED 5, SPIKED 1"
banner hidden: 1
[effect.print] printed story-marcellina-triarius-2 in edition 6
production.wf-instance.5c212766f2fd print done (11s)
Edition 6 is printed. Adding the re-pitch bug and this result to the BUILDLOG, then committing.
$ cd /d/Repos/web-apps/estian-tattler && python - <<'EOF'
p='BUILDLOG.md';s=open(p,encoding='utf8').read()
a="It now means a story with a run still in flight.\n"
assert a in s
s=s.replace(a,a+"- With Pitch back, pitching Marcellina again failed with \"A draft of this document already exists\", because the slugged id was the spiked story's. A new pitch is a new story, so the id gets `-2` when the slug is taken.\n")
a="and the prompt got its last rule before check_draft: only use he or she for someone `who_is` gives a gender for.\n"
assert a in s
s=s.replace(a,a+"\nI re-pitched Marcellina from the Night Desk to test it. The new draft uses her name all the way through, gets the last word right (\"The last word that night went to Marcellina\"), and found an actual angle: insulted twice on Day 305 and chatting with both insulters within hours. It passed with 10 claims, I checked them against the records, and sent it to press from the app. Edition 6.\n")
open(p,'w',encoding='utf8',newline='\n').write(s)
EOF
grep -n "uses her name" BUILDLOG.md
90:I re-pitched Marcellina from the Night Desk to test it. The new draft uses her name all the way through, gets the last word right ("The last word that night went to Marcellina"), and found an actual angle: insulted twice on Day 305 and chatting with both insulters within hours. It passed with 10 claims, I checked them against the records, and sent it to press from the app. Edition 6.
I just did the same thing the reporter did: "her name" and "called her" assume a gender that isn't on file. Fixing both.
$ python - <<'EOF'
p='BUILDLOG.md';s=open(p,encoding='utf8').read()
for a,b in [("uses her name all the way through","says \"Marcellina\" all the way through"),("It called her \"she\", but visitors","It said \"she\", but visitors")]:
assert a in s,a; s=s.replace(a,b)
open(p,'w',encoding='utf8',newline='\n').write(s)
EOF
grep -n -i "\bher\b\|\bshe\b" BUILDLOG.md | grep -i marcellina; git add -A && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -q -F - <<'EOF'
Give a re-pitched story a fresh id
Pitching someone whose story was spiked collided with the spiked draft's
id. Suffix the slug when it's taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
EOF
git push -q origin HEAD && git log --oneline -3
86:Marcellina Triarius, a visitor with 29 records, went the other way. The draft passed with 7 claims, and every one checked out against its records. I spiked it anyway. All 29 records are chats, so two days of small talk was the whole story. It said "she", but visitors come through the ingest with no gender, so that was a guess from the name. And it ended with "the last word so far went to Aquila Summanus" on a record where Marcellina is the one talking. That's the third thing the checker can't see after absence and comparison: a claim that cites the right record and reads it backwards.
warning: in the working copy of 'nightdesk/src/Coverage.tsx', CRLF will be replaced by LF the next time Git touches it
8256e27 Give a re-pitched story a fresh id
f27346a Tell the reporter when a pawn's gender isn't on file
3d798ec Add the Night Desk, an App SDK dashboard for the story desk
$ for id in 4735415 4741450 4720481; do curl -s "https://dev.to/api/articles/$id" | python -c "
import json,sys;a=json.load(sys.stdin);print('=====',a['title'],'|',len(a['body_markdown']),'chars');print(a['body_markdown'][:3200])"; done
===== INKSHIFT: cross out a table, keep the booking | 13036 chars
*This is a submission for the [Sanity Challenge, Path Two: Vibe-Code Something Strange](https://dev.to/challenges/sanity-2026-09-16).*
## What I Built
Someone has booked Ticket to Ride at Table B. Then Table B becomes unavailable.
Moving the game to Table C sounds easy. But there is already a person attached to that plan, and their booking needs to survive the edit.
I built [INKSHIFT](https://inkshift.vercel.app) for this kind of change. It turns a plan for a games night, workshop or club meetup into a shared signup page. Upload a photo or type the plan, check the sessions, and send the invite link. Guests can book a place without creating an account.
When the plan changes, you review the proposed edits against the people who have already joined. You can correct the reading, check the affected bookings and approve the move. Guests keep their places at the new location.
Sanity gives the gathering continuity: Content Lake stores the linked sessions and registrations, Workflows records each plan review, and App SDK subscribes to the shared schedule. The booking belongs to a session whose location can change.
[Open INKSHIFT](https://inkshift.vercel.app) · [Watch the walkthrough](https://youtu.be/xM5eC-q7t_0) · [Source code](https://github.com/himanshu748/inkshift)
## Demo
The 43-second walkthrough follows a prepared games-night plan through a table change:
{% embed https://www.youtube.com/watch?v=xM5eC-q7t_0 %}
You can try the same flow yourself:
1. Choose **Try a sample** and open its participant invite.
2. Join Ticket to Ride, then return to the organiser workspace.
3. Choose **Use the crossed-out example** and review the proposed move to Table C.
4. Approve it, then reopen the participant page.
Here is the organiser's review with a booking already in place:

The review identifies the move and the registration that stays with it. INKSHIFT checks that Table C has enough seats and is available for the full session before allowing approval.
After approval, the guest's existing booking appears under **Your places** at Table C:
]), "cited": count(array::unique(*[_type == "story" && defined(printedAt)].body[].markDefs[_type == "claim"].records[]._ref)), "records": count(*[_type=="record"]), "pawns": count(*[_type=="pawn"]), "kinds": {"tale":count(*[_type=="record"&&kind=="tale"]),"talk":count(*[_type=="record"&&kind=="talk"]),"letter":count(*[_type=="record"&&kind=="letter"]),"message":count(*[_type=="record"&&kind=="message"])}, "eds": *[_type=="story" && defined(printedAt)]|order(edition asc){edition,headline,"claims":count(body[].markDefs[_type=="claim"])}}'; git log --oneline | wc -l; gh api repos/Booyaka101/estian-tattler/commits/8256e27/check-runs --jq '.check_runs[]|.name+" "+.status+" "+(.conclusion//"")'
front.png
nightdesk.png
receipts.png
story.png
workflows.png
{"cited": 136, "eds": [{"claims": 8, "edition": 1, "headline": "Snake and Grasshopper: the marriage, the split, the binges"}, {"claims": 9, "edition": 2, "headline": "The berry pile keeps rotting"}, {"claims": 12, "edition": 3, "headline": "Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo"}, {"claims": 8, "edition": 4, "headline": "Eastern Republic sours on the restaurant: \"Score: -12\""}, {"claims": 7, "edition": 5, "headline": "Flubber Flubber's Grave Draws a Faithful Few"}, {"claims": 10, "edition": 6, "headline": "Marcellina Triarius: Insulted Twice, Still Chatting"}], "kinds": {"letter": 53, "message": 149, "tale": 354, "talk": 75}, "pawns": 17, "printed": 6, "records": 631}
7
$ curl -s https://booyaka101.github.io/estian-tattler/ | grep -o "Insulted Twice[^<\"]*" | head -2; gh run list -R Booyaka101/estian-tattler -L 3
Insulted Twice, Still Chatting
Insulted Twice, Still Chatting\
completed success print Front page main repository_dispatch 36150129162 38s 2026-09-25T14:50:05Z
completed success print Front page main repository_dispatch 36147881983 39s 2026-09-25T14:30:03Z
completed success Quieter claim underlines, front page screenshot Front page main push 36145136428 40s 2026-09-25T14:05:12Z
Edition 6 is live on the front page. Next I'm refreshing the front-page screenshot so it shows all six editions, then writing the post draft.
D:\Repos\web-apps\estian-tattler\docs\front.png
[image removed — image/png, stripped by compact-transcripts]
D:\Repos\web-apps\estian-tattler\docs\story.png
[image removed — image/png, stripped by compact-transcripts]
$ ls /c/tmp/demo-shots/; grep -l "estian\|front" /c/tmp/demo-shots/*.mjs 2>/dev/null
agent-trace-raw.png
agent-trace.html
agent-trace.png
answer-raw.png
answer.html
answer.png
check-raw.png
check.html
check.png
cover.html
cover.png
dev_draft.mjs
dev_image_urls.json
line.txt
nd1.png
nd2.png
nd3.png
nd4.png
nd5.png
nd6.png
nd7.png
nd8.png
preview.png
render.py
shot.mjs
studio_do.mjs
studio_errors.mjs
tattler
verify_draft.mjs
$ cd /d/Repos/web-apps/estian-tattler/frontpage && grep -rn "className=\"claim\|receipts\|tabIndex" app components 2>/dev/null | head -8; ls app
app/page.tsx:49: not a model, rejects any claim naming a pawn or quoting a number its receipts don't contain. Failed drafts
app/story/[id]/page.tsx:4:import {getPaper, getStory, receiptsOf, slug} from '@/lib/tattler'
app/story/[id]/page.tsx:17: const receipts = receiptsOf(story)
app/story/[id]/page.tsx:30: <summary>All {receipts.length} records this story cites</summary>
app/story/[id]/page.tsx:32: {receipts.map((r) => (
app/tattler.css:176:.receipts {
app/tattler.css:192:.receipts::before {
app/tattler.css:202:.claim:hover .receipts,
layout.tsx
page.tsx
story
tattler.css
$ cd /c/tmp/demo-shots && node shot.mjs "https://booyaka101.github.io/estian-tattler/" /d/Repos/web-apps/estian-tattler/docs/front.png 6000 1280 1250 && node shot.mjs "https://booyaka101.github.io/estian-tattler/story/berry-rot/" /d/Repos/web-apps/estian-tattler/docs/story.png 6000 1280 1000 "(()=>{const c=[...document.querySelectorAll('.claim')].find(e=>/39/.test(e.firstChild?.textContent||e.textContent));if(!c)return 'none';c.scrollIntoView({block:'center'});scrollBy(0,-150);c.focus();c.classList.add('hover');return c.textContent.slice(0,120)})()"
https://booyaka101.github.io/estian-tattler/ | The Estian Tattler
"Between Day 264 and Day 296, the colony logged berries rotting away in storage 39 times. Day 264, 20h message Berries x7"
https://booyaka101.github.io/estian-tattler/story/berry-rot/ | The Estian Tattler
D:\Repos\web-apps\estian-tattler\docs\story.png
[image removed — image/png, stripped by compact-transcripts]
D:\Repos\web-apps\estian-tattler\docs\front.png
[image removed — image/png, stripped by compact-transcripts]
Both screenshots look right: the front page shows edition 6, and the berry story has its 39 receipts open. Now I'm checking that the public query URL for the post works without a token.
$ Q=$(python -c "import urllib.parse;print(urllib.parse.quote('*[_type==\"story\" && defined(printedAt)] | order(edition asc){edition, headline}'))"); echo "https://lcvgtfvq.api.sanity.io/v2025-02-19/data/query/production?query=$Q"; curl -s "https://lcvgtfvq.api.sanity.io/v2025-02-19/data/query/production?query=$Q" | head -c 400; echo; Q2=$(python -c "import urllib.parse;print(urllib.parse.quote('count(*[_id in path(\"drafts.**\")])'))"); curl -s "https://lcvgtfvq.api.sanity.io/v2025-02-19/data/query/production?query=$Q2" | head -c 200
https://lcvgtfvq.api.sanity.io/v2025-02-19/data/query/production?query=%2A%5B_type%3D%3D%22story%22%20%26%26%20defined%28printedAt%29%5D%20%7C%20order%28edition%20asc%29%7Bedition%2C%20headline%7D
{"query":"*[_type==\"story\" \u0026\u0026 defined(printedAt)] | order(edition asc){edition, headline}","result":[{"edition":1,"headline":"Snake and Grasshopper: the marriage, the split, the binges"},{"edition":2,"headline":"The berry pile keeps rotting"},{"edition":3,"headline":"Who hits whom: Embaixada, Grasshopper, Snake and Rocaniraalbo"},{"edition":4,"headline":"Eastern Republic sours on the r
{"query":"count(*[_id in path(\"drafts.**\")])","result":0,"syncTags":["s1:V69Mhg"],"ms":1}
The public query works with no token, and the drafts don't leak. Writing the post draft now.
D:\Repos\web-apps\dev-posts\sanity-path-two.md
File created successfully at: D:\Repos\web-apps\dev-posts\sanity-path-two.md (file state is current in your context — no need to Read it back)
A few lines in the draft say more than I actually know. Tightening them.
$ cd /d/Repos/web-apps/dev-posts && python - <<'EOF'
p='sanity-path-two.md';s=open(p,encoding='utf8').read()
for a,b in [
("since the build process is half of what's judged","since the build process is part of what's judged"),
("Then it went looking at what I actually use, found my RimWorld saves, and noticed the save is basically a colony's gossip with timestamps.","It knew I play heavily modded RimWorld, and it noticed that a save file is basically a colony's gossip with timestamps."),
]:
assert a in s,a; s=s.replace(a,b)
open(p,'w',encoding='utf8').write(s)
EOF
grep -c "—" sanity-path-two.md; cd /d/Repos/web-apps/estian-tattler && git add docs && git -c user.name=Booyaka101 -c user.email=owner@example.com commit -qm "Refresh front page and story screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" && git push -q && git log --oneline -1
0
1f61b4c Refresh front page and story screenshots
The draft is in place. Next is the Agent Session. I'm opening DEV's upload page in my own tab to see what format it takes. This is read-only for now.
I’m looking forward to reading this series by Tim Pratley. He’s an author and engineer I have a lot of respect for and I’ve grappled with a lot of similar thoughts he raises here. I always appreciate data-driven arguments and an approach to topics like this based on convincing your audience with evidence.
When everyone has a chance to participate in the economy we get more ideas, more productivity, and more opportunity. Extreme wealth concentration blocks innovation and limits broad prosperity.
This only makes sense to me. I think disagreement arises around what we mean by “participate in the economy”. I’d argue that working for wages to survive is not meaningful participation. When people aren’t free to pursue their ambitions or desired careers for whatever reason (sometimes education is too expensive, or maybe they’re stuck at a current employer because they need to retain certain benefits, maybe they can’t afford to move to where the opportunities are, maybe the job market isn’t recognizing their skills, whatever), that limits their potential contributions to society in ways that only limit innovation and degrade overall prosperity.
My nephews and nieces have entered a workforce that offers none of the opportunities I had. Humanity is on a long-term positive trajectory, driven by knowledge creation and sharing, yet we are clearly self-inflicting unnecessary pain in the short term.
This has been on my mind a lot more since a had a kid earlier this year. The world is already unrecognizable to me relative to when I entered the workforce. The way I got into tech and built my career would never work for someone starting out today. I wonder what the world will be like by the time my child reaches working age. I really hope we’ve figured out some of these problems by then and want to be part of the solution.
link / clojurecivitas.org (via) / #careers #economics #reflections #society #workforce
Clojurists Together is excited to announce that we will be funding 4 projects in Q3 2026 for a total of $22K USD (2 projects at $9K each and 2 shorter or more experimental projects for $2K each).
Thanks to all our members for making this happen! You’ll find more information about the proposed projects below.
Congratulations to our awardees. We’re looking to their great work ahead!
The goal of this project is to significantly improve the documentation and marketing of four established community projects.
Tablecloth Column API, Phase II
This project continues my earlier Clojurists Together work that added a Column API to tablecloth, a core library in Clojure’s data science toolkit. The goal is to make the Column API
more capable and easier to use. Planned work includes refactoring the implementation so it’s easier to maintain, improving the documentation, and adding more operations from
underlying libraries such as dtype-next and fastmath. I also plan to write a tutorial that shows the API on a real data-processing task. The exact scope may change as the work goes on, in conversation with the Scicloj community.
This project is related to improving EVE (the Extensible Value Encoding) so that it better serves the Clojure community. This includes improving the Clojure implementation (the CLJS
version came first), improving user documentation, getting the library to a “1.0” status and other related tasks.
Finally release the first publicly usable version of Carmine v4!
Carmine is a mature Redis client for Clojure v4 is a complete rewrite with highlights including RESP3 (modern Redis protocol) support, better performance, better connection management, and support for both Redis Sentinel and Cluster. Also a much better message queue. I’ve been working on v4 for years, it’s been a massive undertaking. The core’s been basically done for a while, and recently finished draft Sentinel + Cluster support, plus a major redesign of the message queue. Now need one more big push on testing, documentation, and polish to get the first public prerelease out. Estimate 1-3 more months of work, then the usual ongoing support after that.
A few weeks ago TypeSafe released Jev, and their demo of Jev playing Doom in real-time got a lot of excitement. Their argument is that chat models are great at conversation while still being awkward at doing real world tasks which often involve deciding things. Jev is a hosted classifier model which takes unstructured state as input and produces typed probabilistic decisions in a single pass. And what the Doom demo illustrates is that the model is able to do this reliably under a few hundred milliseconds. Since the responses are structured data, there is nothing that can be hallucinated, although the model can still misunderstand the question and produce a wrong classification. They call the category System One models, borrowing Kahneman's name for the fast automatic kind of thinking.
Naturally, people quickly pointed out that the idea behind Jev has been around for a while, and a whole slew of open source projects have popped up implementing the idea. I got curious to see how much work it would take to implement my own Jevlike using Jolt since I can use FFI to drive engines like llama.cpp easily enough.
I wanted to see how well an open weights model running on my own hardware would perform, and to experiment with having an escape hatch for the cases where a fast classifier fails to produce a high confidence answer. So, I proceeded to build Lev which is a decision engine exposing the same wire API as Jev.
This post explains how the classifier tier works, where these models fail, what the benchmarks say about actual Jev performance when people measure it independently, and how the escalation model in Lev addresses these shortcomings.
An LLM reads input left to right and has to commit to each token before seeing what comes next. When you ask a chat model the same question, it generates an answer token by token, you parse the string, and if you want a confidence interval you have to ask for one in the prompt. Generation is the right tool for open-ended work, but it turns into an expensive detour when there is a known set of answers. Encoder models like ModernBERT are a better fit here because they read your entire input bidirectionally in one go. So, for a choice question, each option gets a marker token placed in the sequence next to the option's text. After the forward pass, a small head turns each marker's final representation into a score, and a softmax across the scores hands you a probability for every option simultaneously.
And that's the main part of the reason why these models are so fast. A 395M parameter encoder doing one pass over a few hundred tokens is cheap, and the cost grows only a little with the number of options it sees. These checkpoints were also trained with reinforcement learning on calibrated decisions, which means the probabilities are supposed to track how often the model is right in practice, so a 0.9 is meant to represent being right nine times out of ten. The confidence interval is the key to deciding how likely the result is to be correct, and we'll come back to it later since it carries a lot of weight in the design.
The failure modes for BERT style models appear to be consistent across every family I looked at. The first one is that classifiers interpolate but they aren't able to do deduction. They do great on traffic that looks like their training data, but if the question is a double negative or some other adversarial phrasing then they crumble. Lev's own adversarial set, 144 authored three-way decisions, was built to trip exactly this type of failure. The encoder tier models consistently score 61 to 67 percent on it. On the other hand, a local 2.5B LLM with thinking enabled scores 95 percent on the same set, because it runs a reasoning loop before answering. The downside is that running an LLM with reasoning on takes around 3 seconds using the GPU on my laptop, so it's not a drop in replacement for a classifier if you actually care about performance or efficiency.
Another failure is abstention, when you ask a classifier "is there enough information here to answer" it will almost always say yes, because the abstaining option rarely won during training. Lev's encoder picks the insufficient-evidence option 18 times where the data says it should pick it 36 times, and that's the exact same shape that shows up in Jev incidentally. An independent benchmark found it admits ignorance on 49.7 percent of forced-uncertainty items, where the LLMs tested admitted it 97 to 100 percent of the time.
There is also position bias to consider, where shuffling the order of the options and the answer sometimes changes. Around 13 percent of Jev's choices flip under permutation in one published measurement, and Lev's encoder also moves about 14 percent of its choices the same way. So, any system built on these models has to ensure that the option order the model sees as input is normalized.
Finally, there is the problem of overconfidence since the model can be sure of its output while being wrong. Softmax outputs are not honest odds, and their calibration being fitted on one distribution ends up drifting when the traffic looks different. The worst measured Jev calibration error in that same independent run was 0.246, and on the DAIR Emotion benchmark Jev scored 0.480 putting zero probability on the true label for 16 percent of examples.
None of this says the category is bad, it just means that the approach works best for a specific set of scenarios, and problems outside this set can be escalated to a different type of model. TypeSafe's own evals, for what it's worth, score Jev against the consensus of two frontier LLMs rather than against ground truth, so agreement with a big model is doing the work correctness would normally do. Independent measurements land Jev at 66 percent on a 150-passage test, tying Claude Haiku 4.5, and at 76.3 percent on a 77-way banking intent set against 81.3 for an open 120B model. So, the approach gives you respectable results at much lower cost while having honest-ish uncertainty. It's clearly a useful tool if you understand what types of problems to apply it to.
The main innovation with Lev is that it routes between two very different kinds of model under the hood. First, a small classifier encoder is used that answers in about a tenth of a second on a laptop CPU, and when a low confidence answer is produced, the query is escalated to a local thinking LLM. The whole project compiles to a single standalone binary that runs entirely offline.
The fast tier is a set of encoder checkpoints from the convaiinnovations/laya release on the Hub, Apache-2.0 weights: an English ModernBERT-large, a typed-decisions variant with a longer context, and a multilingual one covering a hundred plus languages. A router looks at the request and picks an encoder by content and language unless you name the model explicitly. These run in one forward pass that covers every question in the call, roughly 95 milliseconds for a short question on my laptop's CPU.
The slow tier is any GGUF chat model loaded through a statically linked llama.cpp. It reads the state and scores its candidate answers by their token log probabilities, which is how you can pry honest numbers out of a generative model. Lev configures Qwen3.5-4B as its escalation model by default. Turns out that scoring allows skipping the thinking pass entirely, and still scores 95.1 percent on the adversarial 144-case set I mentioned earlier.
Now, if you'll recall, the LLM approach has a significant performance drawback running using stock llama.cpp, but it turns out that there is a brilliant parallel decision fork of llama.cpp which addresses the problem. Instead of forcing the model to spit out a JSON string token by token, which involves running a full forward pass per token, it frames the schema as a single token multiple choice problem. And the genius is the KV cache management because it processes base instructions and schema once on load, caching that state in VRAM. When a batch of questions comes in, all of them point to the same shared cache and just append a few tokens at the end for their specific field names. Since the heavy lifting of reading the context has already been done, the model only needs one forward pass to check the probability scores of your predefined choices. It ignores the rest of the vocabulary, evaluating every field in parallel to produce answers in milliseconds. Running the LLM still requires using the GPU, but in terms of raw speed it's now comparable to using the classifier.
Another part worth noting here is the confidence gate that Lev uses. On the adversarial set the encoder alone gets 61.1 percent at 117 milliseconds a case and using Qwen3.5-4B alone gives 95.1. Gating the encoder at a 0.5 confidence threshold leads to 126 of the 144 cases escalating, giving 92.4 percent at about 270 milliseconds a case. The gate errs conservatively on traffic that was designed to trip a classifier, and pretty much the whole set gets handed to the LLM, addressing the problem of the encoder being overconfident. But on a 120-case set of AG News, BoolQ, and SST-5 it answers every question in one forward pass at 65.8 percent accuracy and about 0.13 seconds per case, keeping most of its best task on the fast tier and escalating its worst. Across a mixed stream the encoder takes the easy majority while the thinker is reserved for the rest.
However, the gate does have a blind spot which is that a yes-or-no question's confidence is max of p and 1 minus p, meaning that it can never drop below 0.5, leading the encoder to be overconfident on that particular question shape. Every single BoolQ case stayed on the encoder at 72.5 percent accuracy, and even raising the noul threshold to 0.7 kept 37 of 40 cases on the fast tier. Because choice and score confidences are well behaved, the fix is to use per-type thresholds rather than a single number for everything.
Since calibration carries all this weight, Lev ships a refit tool which can be handed labeled cases from your own traffic to fit a specific temperature per question type and option-count bucket, minimizing the negative log likelihood. On the published laya numbers that kind of refit moves mean calibration error from 0.466 to 0.081, and on Lev's own buckets the 20-way choice error drops from 0.57 to 0.10. Temperature scaling keeps every argmax, so the answers themselves don't change, only the confidence which the gate reads is affected. There is also a debias mode that asks every wide choice once per rotation of its options and averages the probabilities.
All of this has been pretty abstract so far, so let's look at a concrete example of how a decision happens by seeing how the encoder plays snake in an example found here. A snake has to hunt for food without running into itself with every move being a decision made by the model.
Each tick the game computes the legal moves and which moves are actually safe using the Hamiltonian cycle that the board is built on. Then the policy renders the board as a two-fact state string, and that's everything the model sees here:
Safe route: yes. Food reachable through empty cells: yes.
and it is asked three typed questions in one call:
{"move" {:type "choice"
:instructions "Choose the best safe move toward food."
:criteria {"up" "Safe. Best route to food."
"down" "Blocked. Wall below."
"left" "Unsafe. Traps the snake."
"right" "Safe. Eat food now. Best."}}
"risk" {:type "noul" :instructions "Is a safe route available?"}
"food" {:type "noul" :instructions "Is food reachable through empty cells?"}}
The answer comes back with a probability over all four directions plus the two noul probabilities looking something like this:
{"move" {"type" "choice" "choice" "right"
"probabilities" {"up" 0.11 "down" 0.02 "left" 0.04 "right" 0.83}
"confidence" 0.83}
"risk" {"type" "noul" "noul" 0.88 "confidence" 0.88}
"food" {"type" "noul" "noul" 0.91 "confidence" 0.91}}
The argmax of the choice becomes the proposal used by the safety shield to clamp it to the planner's safe directions, preventing the snake from trapping itself, and the HUD shows the milliseconds the decision took along with how often the shield intervened.
You can press G to turn the shield off and watch how the raw model plays without any guardrails. The first wall-or-tail answer ends the game, usually within seconds. That toggle illustrates the whole philosophy of the project since a fast classifier proposes while deterministic code disposes, and the boundary between the two is what ensures reliability of the system as a whole. The same pattern applies to real world production traffic such as a bundled email workflow where you'd strip quoted history and cap the body before the model sees it. Its questions can then be tied together with constraints decided jointly after the pass, so a phishing mail can't come back as needs-reply just because its body reads like an ordinary question.
The key benefit of Lev is that it's a binary running on your machine using open models, and you don't have to pay for a subscription or send your data to a third party. You can use calibration to refit on your own labeled traffic, use a constraints decoder to tie questions together, and a per-type escalation gate to tune against your own data. It even lets you set up highly specific escalation gates for each category to route uncertain edge cases based on your actual local data distribution.
The limitation of Lev is that you have to define the decision space up front. The questions, the types, the option texts, all must exist before you can ask Lev to do classification. Lev also can't generate anything, so summarization, drafting and extraction-as-prose are simply outside its scope. There is also a limited context budget of 512 tokens on the English checkpoint, and whatever doesn't fit gets dropped from the end of the state, with the answer reporting what was cut. As mentioned earlier, calibration drift becomes a problem when traffic patterns shift, so the refit becomes an ongoing maintenance task if you expect your data patterns to change.
Jev also handles auto classification, bypassing standard text generation entirely. It also has a large context allowing it to sort large datasets like thousands of support emails into specific categories in milliseconds. This gives you a built in triage filter where you can confidently automate all the high probability classifications and instantly escalate any uncertain edge cases to a heavier System Two model for deeper reasoning. It also runs at an absurdly low cost of around four cents per million input tokens which makes running massive classification workloads incredibly cheap and fast.
Python has become the staple for working with machine learning and data engineering largely because it provides an easy to use API on top of the native ecosystem. However, Python also has plenty of downsides to it such as poor performance, ad hoc dependency management, and lack of a decent packaging story. All of which have been addressed in Clojure from day one.
However, Clojure has been constrained to the JVM, making it a poor fit for use cases where you want to leverage the native ecosystem. While it's possible to do FFI from the JVM, it remains an awkward experience. Project Panama asks you to deal with a linker object, a symbol lookup, a function descriptor built from value layouts, a method handle, and an arena that owns native memory that the call touches. Strings can't cross the boundary on their own, so you have to allocate a UTF-8 segment, fill it, and then arrange for it to be freed. Structs need hand-written layout descriptions with their alignment and padding. There is a ton of ceremony between you and the function you wanted to call, and on top of all that, you still need the JVM itself, creating additional overhead.
On the other hand, jolt.ffi goes completely the other way, simply needing a per-platform name map to load a library, and each C function becomes a declaration that names the symbol and lists argument and return types as keywords. Strings pass the boundary seamlessly as ordinary Clojure strings. Raw memory is managed by a handful of obvious primitives such as allocate, read, write, and free. Variadic functions take a varargs marker in the same declaration, where the JVM forces a specialized handle per call shape. Jolt aims to make working with the native ecosystem completely seamless, making it as easy to work with the native ecosystem from Clojure as it is from Python.
Jolt also fully supports interactive Clojure workflow, so you can start Lev via nREPL, connect your editor to it, and talk directly to the running process. You can send code like the following to the process and see how it behaves immediately:
user=> (require '[lev.agent :as ag])
user=> (def agent (ag/load-agent "data"))
user=> (ag/system-one agent
"Charged twice this month, want my money back."
{"intent" {:type "choice"
:instructions "What does the customer want?"
:criteria {"refund" "money back, disputes, chargebacks"
"help" "how-to, configuration, questions"
"other" "everything else"}}})
When an answer looks wrong you just redefine the question or the workflow's state shaping in the editor, evaluate it, and check again against the loaded weights. The snake game is basically this loop with a visualizer attached to it. All the calibration constants and gate thresholds cited in the bench numbers above were arrived at by poking a live system this way.
Another major benefit is dependency management using deps.edn. The snake example is a separate project that depends on the engine checkout, and the whole declaration is simply this:
{:paths ["src"]
:deps {lev/lev {:local/root "../.."}}
:jolt/native [{:name "raylib"
:darwin ["/opt/homebrew/lib/libraylib.dylib" "libraylib.dylib"]
:linux ["libraylib.so.6" "libraylib.so"]}]
:aliases {:test {:extra-paths ["test"]
:main-opts ["-m" "snake.test-runner"]}
:run {:main-opts ["-m" "snake.core"]}}}
Local checkouts, git dependencies, Maven and Clojars artifacts, and build tasks all live in a single deps.edn file. Even the native libraries the project binds are declared here. The equivalent Python setup requires a venv or uv environment along with a requirements file, and a wrapper package for every C library.
And then there's the release packaging story. With Lev you can just run jolt binary to produce a standalone executable with the C kernels and llama.cpp linked in statically. Deploying Lev involves copying an executable next to your prepared model data. It doesn't need an interpreter or a separate runtime installed on the machine, and you don't have to muck around with site-packages or containers.
For my own use, Jolt has become a viable alternative to Python, letting me use the native ecosystem completely seamlessly. The FFI binds C shared libraries with minimal fuss, so ICU for tokenization, BLAS through Accelerate, raylib for the game window and llama.cpp for the thinker are all just libraries the project links, declared in deps.edn along with everything else. While giving up the Python ecosystem might seem like a loss, the reality is that a lot of Python libraries are just thin wrappers around native code anyways. So, it's easy enough to just use the native packages directly from Jolt. On the flip side, you get REPL driven development, real dependency resolution along with the Clojure library ecosystem, a fast runtime, and a self contained binary you can distribute.
The engine, the bench harnesses behind every number above, and the snake example are all in the repo. You just have to grab a BERT model and a GGUF, then reference them in the config to get up and running.
A technical overview of a fraud detection demo: historical training inputs, live scoring, and reproducible results using XTDB.
Suppose five agent attempts branch from the same twenty thousand token context. Each attempt then sends the shared context again, and a conventional inference server has to process it again. The work is identical every time. This article describes how pretrained-rstr, an MIT licensed inference component from replikativ, turns that state into a value with an identity, so it can be stored, found, restored, and forked instead of recomputed.
In a typical decoder language model, processing the prompt writes one key and one value vector per layer per token. That memory is the KV cache, and every later token is produced by attending over it. Filling it, called prefill, pushes every prompt token through every layer in parallel. Producing each answer token afterwards pushes one token through those layers, conditioned on the prefilled cache. A long prompt therefore dominates the cost of a short answer, and this cache is the most expensive memory in the system.
Existing serving engines have treated that memory as an internal optimization. vLLM and SGLang keep a prefix cache so a repeated prompt within one process is cheaper. A newer layer of systems, led by LMCache, moves KV chunks across GPU, CPU, disk, and remote tiers so they outlive a process and can be pulled by another machine. What none of them hand the caller is the cache as a value: something with a name that can be looked up, restored on a different worker, forked, and reasoned about alongside the rest of an organization’s state. LMCache was the starting point for this work.
pretrained-rstr separates three questions that a process-local cache never has to answer, and gives each one to the layer that can answer it durably.
What is this state? Datahike holds the answer: each chunk’s content hash, its parent in the token prefix, the model fingerprint it is compatible with, and where a ready replica lives. These are facts, queryable with Datalog, and they never include tensor bytes.
Where are the bytes? Konserve holds the immutable, content addressed chunks, over a local memory-mapped store or an S3-compatible backend. A chunk is written once and never modified.
Who is computing on it right now? Raster, the typed tensor compiler for Clojure underneath, owns the resident page pool on each worker: allocation, sharing between continuations, copy on write, eviction, and the compiled attention graphs that read the pages.
Two movements connect the layers. Publish flows down, from pages to chunks to catalog. Restore flows up, from a catalog lookup to verified chunks to resident pages. The rest of this article follows those movements.
The unit being published and restored is a continuation, and it has one exact definition. For a continuation with processed-count = n, the attention state covers positions [0, n), the pending token is evaluated at position n, and the token history includes that pending token. No logits and no transient activations are part of it.
That definition is deliberately small, and smallness is what makes it portable. It holds for CPU execution, contiguous GPU execution, and paged GPU execution alike, so a continuation captured on one path resumes the same causal computation on another. Replaying a transcript instead would rebuild an approximation of that computation from text. The continuation model states the invariants in full.
Reuse is only safe if two caches that claim to be the same prefix really are. A chunk’s identity is a Hasch content hash that commits to both its token ids and its parent chunk’s hash. Identical suffix text under two different prefixes therefore produces two different identities. A lookup walks the chain from the root and stops at the first missing or incompatible node, which is exactly the longest reusable prefix.
A second identity covers the executor rather than the tokens. A compatibility fingerprint hashes the weights, the architecture descriptor, the model configuration, the attention-state layout, and a named execution variant into one value. A Q4 packed run and a Q8 packed run of the same checkpoint produce different numbers, so they carry different fingerprints, and state from one cannot be restored into the other.
Neither identity is needed inside a single process, because nothing outside the process ever sees the cache. Both are needed the moment the cache is meant to outlive the process or move between machines.
Storage and execution want different granularities.
| Unit | Typical size | Owned by | Purpose |
|---|---|---|---|
| Durable chunk | 128 to 512 tokens | Konserve and Datahike | hashing, transfer, catalog publication |
| GPU page | 16 to 32 tokens | Raster page pool | allocation, sharing, copy on write, eviction |
A 256 token chunk scatters into sixteen 16 token pages on restore. Changing the page size does not change a chunk’s identity, and changing the chunk size does not change the attention result. The store can therefore optimize for object count and sequential transfer while the executor optimizes for allocation and prefix sharing.
The operations above are cheap because pretrained-rstr owns the memory they act on. Raster allocates the page pool, and the attention kernels read pages through resident buffer views bound straight into the compiled graph. A lease pins pages while a graph holds a view. A generation counter stops a late transfer from completing into a page that has since been reused. A checkpoint is a retained device-to-host event on pages the pool already governs, and a restore retains the memory-mapped chunk inside the upload event until the device has consumed it. Forking is a page-table edit under the same refcounts.
A cache layer that sits beside an engine cannot do this. LMCache must first detect which of several physical KV layouts vLLM, FlashInfer, or an MLA model handed it, then copy through per-vendor kernels into its own buffers at the engine’s connector points, because the engine’s block manager owns the pages and does not share that authority. That is the right design for a layer that serves vLLM, SGLang, and Dynamo alike, and it is why it cannot offer a fork.
This trade is not free. Owning the runtime means writing the kernels and losing the PyTorch ecosystem and its attention libraries. Raster currently reaches Intel GPUs through Level Zero and Intel, NVIDIA, and AMD GPUs through compatible OpenCL drivers; native CUDA copy streams remain engineering work. pretrained-rstr accepts that in exchange for one memory model that covers kernels, transfers, durability, and branching, on the models it targets.
Pages are immutable until written, so two continuations can share every page of a common prefix. A fork allocates nothing. When one branch appends a token into a partially filled page, that page alone is copied, and from then on the two page lists differ in one entry. The diagram above shows that state: the fork references p0 through p2 and owns its own p3′.
This is the same copy on write argument that cheap isolated branches make for structured data and that a forkable REPL makes for interpreter state, applied to GPU memory. It also has the same shape of limit. Two branches can advance independently from a shared prefix, but there is no semantic merge for divergent attention state. Choosing which branch matters is a decision the application makes, not one the cache prescribes.
Publish is ordered so that the catalog cannot advertise state that does not exist. A checkpoint captures immutable tensor ranges from the page pool, makes the local chunk durable, waits for the authoritative backend’s receipt when write-behind is configured, and only then transacts the chunk identity into Datahike. A chunk that exists solely in a failed worker-local write is never visible to a lookup.
Restore applies the same discipline in reverse. It verifies each chunk’s content identity and fingerprint before marking the replica ready, and a continuation becomes runnable only after every required page has landed. Datahike is consulted for the lookup and for placement, not per token. Workers keep their hot scheduling state locally, so Datalog queries and network round trips stay off the decode loop.
Once state has an identity, a request can be sent to the worker that already holds its prefix rather than moving tensors to the request. The cluster router ranks candidates by exact prefix and predicted time to first token, but its decision is advisory. The selected worker remains the authority for its own device memory: it revalidates the offer against current page state and reserves the projected prompt-plus-generation capacity atomically before accepting. Kabel carries only control messages and token results between them. Tensor bytes stay on the Konserve path.
Clients can access this through an OpenAI-compatible ingress. It accepts a small tested subset of POST /v1/chat/completions, translates HTTP, JSON, chat templates, and tokenization into an ordinary continuation request at the edge, and streams tokens back over server-sent events. From a client, only the base URL changes:
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8080/v1", api_key="local")
result = client.chat.completions.create(
model="gemma-3-270m-it",
messages=[{"role": "user", "content": "Explain KV caching briefly."}],
stream=True,
stream_options={"include_usage": True},
)
The response reports what the routing achieved. usage.prompt_tokens_details.cached_tokens carries the count the worker’s restore returned, not the router’s estimate of what should have been resident. Tool calls, structured outputs, and multimodal content are later slices; the serving boundary lists exactly which fields are accepted today.
Two ideas here are shared with the field. Chaining each chunk’s hash through its parent prefix is now standard: vLLM’s automatic prefix caching, SGLang’s RadixAttention, LMCache’s chunked token database, NVIDIA Dynamo’s KV indexer, TensorRT-LLM, and llm-d all do it. Persistence beyond a process is also no longer rare: LMCache’s remote backends, vLLM’s tiered offloading to a filesystem or S3, and llm-d’s use of that tier all survive a restart, while Dynamo deliberately treats KV as transient and recomputable.
The differences are in what is treated as identity and what is handed to the caller.
| System | Compatibility key | Lineage and placement | Fork exposed to caller |
|---|---|---|---|
| LMCache | model name, parallel layout, dtype | internal index and controller API | no |
| vLLM tiering | digest of run config: model, block size, parallelism, dtype | on-disk layout | no |
| SGLang | model name suffix on the storage key | radix tree, in process | client-side DSL branch over shared prefixes |
| Dynamo | geometry only | global radix tree fed by worker events | no |
| pretrained-rstr | hash of weights, descriptor, attention layout, execution variant | Datahike facts, queryable with Datalog | copy on write on resident pages |
Keying on a model name is enough inside one deployment where the operator controls what that name means. It is not enough once state is shared across deployments or kept for months, and the vLLM project has an open proposal to add a quantization and dtype digest to shared-store keys for exactly that reason. Hashing the weights themselves, and naming the execution variant, closes that gap at the cost of one pass over the checkpoint at load time.
Recording lineage and placement in a general database rather than a purpose-built index is the other choice. LMCache’s controller offers lookup, pin, move, and compress, and its coordinator tracks fleet-wide placement, which is more operational machinery than pretrained-rstr has. What a Datahike catalog offers instead is that a continuation’s parent chain, fingerprint, and replica placement are ordinary facts that can be joined with whatever else the organization records about the attempt that produced them.
No system in the comparison exposes copy-on-write forking of a resident cache as an operation the caller performs. Engines share prefix blocks internally, and SGLang’s frontend can branch a program over shared prefixes, but the branch is not a durable, named continuation. That gap is what our approach fills. LMCache also does things pretrained-rstr does not attempt: non-prefix reuse through CacheBlend, compression through CacheGen, prefill and decode disaggregation over RDMA, and breadth across datacenter hardware. The two are complementary; the identity and catalog layer here does not assume a particular storage tier underneath it.
Current model anchors cover token-exact paged decode, copy-on-write forks, and continuation resume on the supported development hardware. Model-free tests cover content-addressed prefix lookup, catalog and replica state, durable publication, page sharing, admission, eviction, and routed scheduling. Mixed prefill and decode packing, native CUDA copy streams, and production cluster policy remain engineering work.
pretrained-rstr supports a growing set of instruction, embedding, and speech models that an organization can run on its own hardware. When inference and storage are configured locally, prompts, continuations, and provenance do not need to leave the operator’s environment. The state behind an answer can be named, stored, and reused with the same discipline as any other durable value in Datahike. The library is experimental and pre-1.0, and feedback is welcome. It is a replikativ component, not yet a shipped Simmis integration.
The branching property is what makes it more than a cache. When an attempt can begin from an exact prefix another attempt already paid for, the marginal cost of a second attempt is the tokens it adds instead of the context it inherits, and abandoning it costs only the pages it dirtied. The same reasoning already governs structured organizational state in this stack. Applied to attention state, it means the cost of exploring several options no longer scales with context size if they share most of their context window.
If you are interested in running pretrained-rstr, contact us and we will help you get started.
A bit more Datomic, Recovering from a workstation hardware failure
Each week, we discuss a different topic about Clojure and functional programming.
If you have a question or topic you'd like us to discuss, tweet @clojuredesign, send an email to feedback@clojuredesign.club, or join the #clojuredesign-podcast channel on the Clojurians Slack.
This week, the topic is: "pure state and set models". We look at one thing over time and many things at the same time.
You don't want to people to read your code and then curse your name... Or come at you with sharp objects!
What is the secret sauce of the application? Your pure data models!
A pure model for managing state? Pure state. That seems like a contradiction.
I think someone came up with the acronym ACID for a reason: heartburn or bad trip, either way, that's the best case scenario for managing your state in a database.
We're not talking about managing state in some kind of external system, we're talking about managing information in time.
If you have information that changes in time, then you need a pure data model for state.
The future is a function of the past. Your current state goes into a function with some context (an event, an operation, etc.), and your new state comes out.
The point is it's pure. This is pure information, and there are no side effects.
A pure model gives you the vocabulary to discuss it. It gives you information about what's possible and what's not possible.
We want to name our filters and reductions because we work in the realm of semantic information.
We can begin to compose these little primitives, these little parts, into larger and larger things.
Clojure core is like the engine, but it's not the mechanism that you use to work with the model. You put that mechanism in the model functions, and then you're able to work at the higher semantic level: the application domain, not the data domain.
Why not name it? Even if you use it once, giving it a name helps document its intent and purpose, and it helps you understand the code when you come back to it later. It's much better than trying to read a clojure.core composition and deduce the intention. Names are for us.
Planet Clojure is a meta blog that collects posts from the blogs of various Clojure hackers and contributors.
It is edited by Baishampayan Ghose and Alex Ott. Please send them a patch if you want your blog to be syndicated here.



