Reviews — pausing for a human decision
Reviews — pausing for a human decision #
Pause a run for a person's judgement: write a question, deliver the link it mints, then await the answer. The run stops, the person decides, and the run resumes exactly where it left off with every handle and binding it had — minutes or days later, holding nothing open in the meantime.
Reach for one only when a judgement is genuinely needed. A value you can compute, extract, or default with a ?: set-if-empty field is not one.
how-it-works #
asks = connect ask
q = write asks-[:Check]-> { Prompt: "Pursue this company?" }
- The family edge (
Checkhere) is the kind of question, and it fixes the type of the answer. q.Urlis the link a person opens — the only delivery affordance. Nothing is sent for you; you interpolate thatUrlinto a message yourself.await first(q-[:Response]->)is the only way to read the answer.first(…)is the ordinary read — nothing answered yet reads as nothing;awaitin front of it holds the run until there is something to read.
the-families #
Every family takes Prompt (the question) and an optional Detail (a longer explanation shown under it), plus the parameters below.
- Check — approve or decline.
Answeris a boolean,trueif they approved. - Provide — supply one value.
Answer Type(required) is"text","number","date", or"boolean";Answeris that type. - Choose — pick ONE.
Options(required, at least one) is the set;Answeris the option they chose. - Select — pick a SUBSET.
Options(required) again;Answeris the list they ticked, possibly empty. - Review — an acknowledgement. Nothing to fill in and nothing extra to supply: the person confirms they have read the
Detail, andAnswercarries no value. Await it and carry on. - Form — collect several named fields at once.
Fields(required) names them; name them literally and each becomes a field of the answer you read like any record's,a.Budget, every one text.
Spell a parameter out as a literal and the answer narrows while you write it: a Choose over ["ship", "hold"] answers one of exactly those two, and comparing it to anything else is caught then and there; Answer Type: "number" answers a number. Build the options at run time instead and the answer is plain text — still correct, just unnarrowed. A literal Fields narrows the same way, into one field per name.
q = write asks-[:Form]-> {
Prompt: "Fill in the missing details"
Fields: ["Company", "Contact", "Budget"]
}
a = await first(q-[:Response]->)
write company-[:Notes]-> {
Title: "Intake details"
Payload: a.Budget
}
Build those names at run time and there is nothing to read a field off — the editor warns you on save, and the answer stays a.Answer, one structured value carrying them all. A structured value is opaque. Pass it on into a structured field, but you cannot pick a piece out of it, compare it, or branch on it.
So when you need a value to act on — a name to look up, a number to write into a typed field — reach for Provide, Choose, Select, or a Form whose Fields you spelled out.
delivering-and-awaiting #
q = write asks-[:Check]-> { Prompt: "Pursue this company?" }
deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }
write deals-[:Messages]-> { Message: "New company — pursue it? ${q.Url}" }
answer = await first(q-[:Response]->)
if (answer.Answer) {
write crm-[:Companies]-> { unique by (FUZZY Name), Name: `Company Name` }
}
- Deliver the
Urlwherever the person is — a channel post as here, an email, a text message. Whoever opens it gets the control the family calls for; nothing renders that control into the delivery for you. - An
awaiton its own waits indefinitely. For anything unattended, give it a deadline (below). - When the run was started from a chat interface, an undelivered question is relayed: the run pauses, the person is shown the question, and their decision is what answers it — never the assistant's own judgement.
the-pick-idiom #
Letting a person pick a live record — one company out of the several you found — needs no special family. Compose it: offer the names, then filter the graph on the answer.
found = crm-[c:Companies WHERE c.Categories == "Lead"]-> {
return c.Name
}
q = write asks-[:Choose]-> {
Prompt: "Which company did they mean?"
Options: found
}
a = await first(q-[:Response]->)
pick = only(crm-[co:Companies WHERE co.Name == a.Answer]->)
if (pick == null) { fail("no company named ${a.Answer}") }
write pick-[:Notes]-> { Title: "Chosen", Content: "Picked from ${count(found)} candidates." }
Options gathered at run time are ordinary — the answer is plain text rather than one of a known set, which is what it honestly is. The answer names the record; the lookup after it keys on that name.
timeout-and-escalation #
Give an unattended question a deadline by racing the wait for it against a sleep and proceeding with whichever settles first. There is no fallback clause — a deadline, a reminder, and an escalation are all composed from those two pieces:
deals = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (deals == null) { fail("no #deals channel") }
r = race(
() => {
a = await first(q-[:Response]->)
return a.Answer
},
() => {
await sleep(6h)
write deals-[:Messages]-> { Message: "Still waiting: ${q.Url}" }
await sleep(2d)
},
)
decision = r[0]
if (decision == null) {
write deals-[:Messages]-> { Message: "Nobody decided in time — holding." }
}
if (decision == true) {
write crm-[:Companies]-> { unique by (FUZZY Name), Name: `Company Name` }
}
awaitmarks where the run itself waits: on the answer (await first(q-[:Response]->)) and on the clock (await sleep(2d)), inside the arms.racesays only how several waits combine — it settles on the first of them. It does not wait for anything on its own, so it is written withoutawait.- Each arm is a function, passed as an argument:
() => { … }written where it stands, or the name of one declared elsewhere. An arm takes no parameters — it already sees everything in scope where you wrote it — and it starts the moment the combinator does. - The value is a receipt: one slot per arm, in the order written, holding whatever that arm
returned.r[0]reads the first arm's slot,r[1]the second. An arm that hands nothing back has an always-null slot. - Under
race, only the arm that settled first has its slot filled and every other slot is null — so a null test on a slot is the branch, asdecision == nullis here. Arms still parked when one settles are dropped where they stand: their waiting stops, and whatever they had already done stays done. Arms that settle in the same moment all land. - To wait for every one instead, use
all(below): every one runs to the end and every slot is filled. - Arms run at once. Arms writing the same
unique bykey take turns, so the record is made once. - Durations are bare unit-suffixed literals:
30s,90m,6h,2d,1h30m. - To wait on a condition instead — "until this record is marked done" — hand
untilthe condition and the cadence to re-check it on:await until(() => { refresh company return company.Stage == "Won" }, { every: 1h })(…) => { … }is a closure: a body written where it stands and run later, holding everything in scope at the point you wrote it. The run resumes the moment the closure returns true.refreshre-reads a handle so each check sees the record as it is now; a condition that needs no refresh can be a plain boolean expression instead (await until(count(q-[:Response]->) >= 3, { every: 10m })). A condition takes no parameters and may only read — a write or anawaitinside one is refused, as is a cadence under a minute. - Which of the two you write is the relationship's to decide, and you are told when you save. Where the system announces what landed — a question's
Response, a chat thread'sReplies, a callback'sCalled—await first(…)is the form: the run sleeps and is woken the moment it arrives, so there is no cadence to name. Where nothing announces it,await first(…)is refused anduntilwith anevery:is the form, because someone has to say how often to look. Polling something that does announce still works, and is flagged: it just waits longer than it needs to.
several-at-once #
Put one decision in front of several places at once by minting one callback, delivering it everywhere, then awaiting it once: whoever taps first settles it, wherever they were. A button in a channel people already read is the lightest way to put a decision in front of them, so that is what each channel gets:
approve = callback({ })
chat-[ch:Channels WHERE ch.Topic == "approvals"]-> {
write ch-[:Messages]-> {
Message: "Approve this?"
Blocks: [ … ] # one control carrying "${approve.id}"
}
}
r = race(
() => {
tap = await first(approve-[:Called]->)
return tap.At
},
() => { await sleep(1d) },
)
if (r[0] == null) { … } # nobody tapped inside the day
One callback in several places settles on the first tap but says nothing about where it came from — every place carried the same control, so there is nothing to tell them apart. When that matters, give each place its own callback and its own arm. An arm is written where you already know the place, so it can hand that back along with the answer:
deals = callback({ })
board = callback({ })
`Deals Channel` = only(chat-[ch:Channels WHERE ch.Name == "deals"]->)
if (`Deals Channel` == null) { fail("no #deals channel") }
`Board Channel` = only(chat-[b:Channels WHERE b.Name == "board"]->)
if (`Board Channel` == null) { fail("no #board channel") }
write `Deals Channel`-[:Messages]-> { Message: "Approve this?", Blocks: [ … ] } # carries "${deals.id}"
write `Board Channel`-[:Messages]-> { Message: "Approve this?", Blocks: [ … ] } # carries "${board.id}"
r = race(
() => {
tap = await first(deals-[:Called]->)
return { channel: "deals", at: tap.At }
},
() => {
tap = await first(board-[:Called]->)
return { channel: "board", at: tap.At }
},
)
answered = r[0]
if (answered != null) {
general = only(chat-[g:Channels WHERE g.Name == "general"]->)
if (general == null) { fail("no #general channel") }
write general-[:Messages]-> { Message: "approved in #${answered.channel} at ${answered.at}" }
}
- The slot already says which arm settled —
r[0]is the first one written. Handing the identity back is for when it has to travel further: into a message, a write field, a comparison later on. - Return a dict to hand back more than one thing at once; its entries read by name afterwards (
answered.channel).
Wait on different things at once by giving each its own function, and take every answer rather than the first with all, which runs them at once:
r = all(
() => {
b = await first(budget-[:Response]->)
return b.Answer
},
() => {
l = await first(legal-[:Response]->)
return l.Answer
},
)
if (r[0] && r[1]) { … }
- Which field on a control carries the
idis that system's own business, and its chapter says. Reach for a written question instead when you need what one gives you — a page, and an entry in somebody's queue (seewhen-a-question-and-when-a-callback). - Nothing
allruns is dropped, so every slot is filled and none of them needs a null test. There is noawaitin front: a function that waits parks the run, andallfinishes when every one has.
in-chat-answers #
Response is an ordinary writable edge, so anything that can write can answer — a button included. Mint a callback per answer and give each control the callback's id; a tap then resolves the question in place, with no page hop.
q = write asks-[:Check]-> { Prompt: "Pursue this company?" }
yes = callback({ write q-[:Response]-> { Answer: true } })
no = callback({ write q-[:Response]-> { Answer: false } })
The controls carry "${yes.id}" and "${no.id}" — which field on a control holds it is that system's own business, and its chapter says. Keep q.Url in the message text as well, so anyone who cannot tap can still respond.
a-callback-as-a-link #
A callback also has a url — the same block of work reached the other way. The id is what a control carries; the url is what you hand out where there are no controls: an email, a text message, a system whose messages are plain prose.
Opening that url does not act. It shows a page saying what is about to happen and waits for the person to confirm, so a client that fetches links in the background, or a scanner following every link in a message, can never decide on someone's behalf.
how-often-a-callback-can-be-used #
A callback is used once by default: the first person to act settles it, and anyone arriving after is told it is closed. A second argument changes that:
nudge = callback({ … }, { once: false })
today = callback({ … }, { ttl: 4h })
once: false makes it repeatable — every use runs the block again. ttl stops it working after that long. Both are bounded by the run that minted it, which is the shorter leash: a ttl longer than the run's own life changes nothing.
a-value-supplied-when-someone-acts #
Some controls have nothing to pre-wire, because what the person supplies does not exist until they act — a date picker, a text box. Write the deferred work as a closure — (<parameters>) => { … }, a body run later that holds everything in scope where you wrote it — and the supplied value arrives bound to the parameter's name:
booked = callback((day: <date>) => {
write ch-[:Messages]-> { Message: "Booked for ${day}." }
})
A parameter is a plain value the other side hands back — <text>, <number>, <boolean>, <date>, <datetime>, <json>, <file>. It can never be a record position: nothing out there can hand back a row from a connected system, so that is refused while you write it. Anything already known when the control goes out is not a parameter — put it in the body, which captures everything in scope.
The same values come back on the Called landing, one field per parameter, alongside At.
deferring-work-by-name #
A callback's closure can call a NAMED function, fixing the arguments now:
function `Chase`(c: <crm-[:Companies]->>) {
write c-[:Notes]-> { Title: "Chased", Content: "Nudged again." }
}
again = callback(() => `Chase`(company), { once: false })
Whatever the closure takes as a parameter is what the acting side fills in: callback((day: <date>) =>Book(company, day)).
when-a-question-and-when-a-callback #
A callback on its own is the lighter thing: go = callback({ }) with await first(go-[:Called]->) holds the run until somebody presses a button — no page, no queue entry, nothing to open. Write a question when you need what a question gives you: a page rendering the right control for whoever opens the link, wherever they are, and an entry in that person's queue of things to decide — a task directed at somebody. For a decision already in front of the right people in a channel, a callback is enough.
Either way, what a person can act on lives only as long as the run waiting for it. When the run ends the buttons stop working and the link stops accepting, and a late tap is told it is closed. Neither is a standing endpoint: something that must react at any time, forever, is an automation that LISTENS.