The cotal-lang card
Reference (informative) · For: people writing a cotal-lang program · Normative: spec/cotal-lang.md
One page to write a correct workflow program. The normative reference is
spec/cotal-lang.md; this card compresses the parts programs get wrong
first. A program is one module of restricted JavaScript: no imports, no class, no Promise, no
host globals. Every effect is journalled under a step key, so a run can stop on any host and
resume on another with the recorded steps returning instantly.
Effects
Section titled “Effects”| Primitive | Call | Returns |
|---|---|---|
spawn |
await spawn(persona, { name?, worktree?, join?, role?, permits?, supervise?, onFork? }) |
agent handle |
turn |
await turn(agent, { name, deadline? }) |
{ status: "done" | "blocked" | "handoff", to?, note?, at } |
ask |
await ask(agent, { name, schema, deadline?, attempts? }) |
the record the agent published |
checkpoint |
await checkpoint(name, prompt, { schema?, timeout?, onExpiry?, to? }) |
see below |
sleep |
await sleep("10m", { name? }) |
null |
wait |
await wait(event, { name?, timeout? }) |
the event value, null on timeout |
notify |
await notify(agents, fact, { name? }) |
null |
monitor |
await monitor(agent, { name? }) |
null |
parallel, race, fanOut and conclave are the four concurrency scopes (below). Step names
are kebab-case; where the reference says a name is required, it must be a string literal. Option
bags are closed: an unknown key is refused (L3011) with the full signature in the answer.
Durations are a whole number and one unit: "30s", "10m", "4h", "2d".
Results you branch on
Section titled “Results you branch on”A turn yields the agent’s status. An ask yields the record the agent published. schema is
opaque to the language: it is hashed and handed to the handler unchanged. Its handler-side
contract on ask is the shorthand, a record mapping each required top-level field of the reply
to one of "string", "number", "boolean", "array", "record", "null". A handler
enforcing it refuses a schema it cannot read with L4022 rather than skipping the check, counts
each non-conforming reply against attempts, and reports L4006 when they are exhausted. The
reference simulator enforces this. A checkpoint’s schema stays uninterpreted in this
revision. A checkpoint is a durable pause raced against a durable timer:
- resolved:
{ status: "resolved", value?, by?, at, artifact? } - expired with
onExpiry: "proceed", or after an"escalate"hop expires too:{ status: "expired", at } - expired with the default
onExpiry: "fail": throws L4007
const gate = await checkpoint("ship-gate", "Ship 1.4.0 to npm?", { timeout: "4h", onExpiry: "proceed" })if (gate.status === "resolved") { log(gate.value, gate.by)} else { log("expired, holding")}The await rule (L2013)
Section titled “The await rule (L2013)”A call that starts an effect must be awaited where it stands, returned, or passed as a branch thunk to a scope. Anything else starts work nothing waits for, and the validator refuses it.
// refused: L2013const timer = sleep("10m")Valid forms: await sleep("10m"), return sleep("10m") inside a function, or
race({ timeout: () => sleep("10m"), reply: () => turnSomeone() }) as branch thunks. The same
rule covers user functions declared async.
Concurrency
Section titled “Concurrency”parallel and race take branches unevaluated, as a record of thunks. The record keys are the
branch keys and survive reordering; array branches are keyed by index, which shifts when you
insert one (warning L3023). fanOut(items, fn, { name, key? }) runs fn(item, index) per item;
the branch key is key(item), else the item’s string id, else the fan-out is refused (L3021).
async function review(pr) { const seat = await spawn("reviewer", { worktree: pr.id }) return await turn(seat, { name: "review", deadline: "30m" })}const prs = [{ id: "pr-11" }, { id: "pr-12" }]const results = await fanOut(prs, (pr) => review(pr), { name: "review-all", key: (pr) => pr.id })log(results)A branch may not write to anything born outside it. Return values from branches and read the scope’s result instead.
// refused: L2032let seen = 0await parallel({ a: async () => { seen = 1 }, b: async () => { seen = 2 },})Values across effects
Section titled “Values across effects”A value that crosses an effect boundary is frozen on the way back: writing to it is L2031, so
copy it into a fresh record first. Effect arguments must have a canonical form: undefined or a
non-finite number inside one is L3041, a function is L3042. json.stringify is the canonical
form (sorted keys, no spaces), and it refuses what has no canonical form (L4016) rather than
dropping it.
Top refusals
Section titled “Top refusals”| Code | What it refuses | Write instead |
|---|---|---|
| L2013 | an effect call nothing awaits | await it, return it, or pass a thunk branch |
| L2032 | a branch writing outside itself | return from the branch, read the scope’s result |
| L2031 | writing a value that crossed an effect | copy into a fresh record, then write |
| L2012 | a host global by name | the replacement in the message, e.g. json.stringify |
| L2011 | Promise |
the four scopes |
| L1025 | ==, != |
===, !== |
| L1001 | class |
records and functions |
| L4018 | a record, array or function where a primitive is needed | convert explicitly |
| L3013 | a computed step name where a literal is required | a string literal |
| L3011 | an unknown option key | the signature in the refusal |
Every code has a row in the reference’s Appendix A, and the message a refusal prints is that row’s title, so search the reference for it verbatim.