Cotal
DocsAPI Reference
Agent coordinationReference

Workflow runtime contracts

Language behavior

  • Pure code is JavaScript. Loops, records, arrays, closures, template literals, destructuring, try/catch, arithmetic, switch, compound assignment, optional chaining, spread and rest: what you would write anyway, with the parts that hide effects or make meaning depend on the host removed (class, this, new, for...in, ==, labels, regex literals, Math/Date/JSON, promises, generators). Every refusal names its code and the edit that fixes it. The builtins are a short list (keys, map, sort, json.stringify, now(), random()), and arrays, strings and numbers answer their usual methods (xs.map, s.trim(), n.toFixed()) and nothing outside that table. Records and arrays you build are yours to change until they cross an effect boundary; a member you do not own, a host prototype, or a value another branch built is refused with a code, never a surprise.
  • Every effect is journalled and hashed. A step is keyed (scope path, kind, name, occurrence) and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's deadline) and the resume stops with a divergence naming the step, rather than replaying an answer to a question the program no longer asks.
  • Concurrency is visible. parallel, race, fanOut and conclave are the only ways to do two things at once, each branch gets its own journal namespace, and the scope writes its own entry saying how it settled: which arm won a race is a recorded fact, decided by the arms' recorded clocks and declaration order, never by a scheduler. A failure the program itself caused inside the scope settles the entry under its own catalog code (fanOut without a stable key is L3021, kind runtime), so a resume reads the same code the live run threw; a plain failure from the handler records the generic L4000 scope-fault. A value the program throws inside a branch is recorded with that fault, so catch receives the same value on resume, and in a migration's dry walk, as it did live. A branch may not write to anything declared outside it; return the value and read it out of the scope's result.
  • Time and randomness are tamed. now() is the branch's run clock, the end of the last effect it awaited; random() is a seeded stream derived per scope, each draw a number from 0 up to but not including 1. randomInt(n) is floor(random() * n), an integer from 0 to n - 1 for a positive integer n, and pick(xs) draws one element. They replay identically. A builtin ignores arguments past the ones it takes, as JavaScript does, so there is no range form: randomInt(1, 1000) is randomInt(1), which is always 0, and 1 + randomInt(1000) draws from 1 to 1000.
  • Values freeze at the boundary. What crossed into or out of an effect is what the journal recorded, and it cannot change afterwards; build a new value.
  • The journal is the debugger. Every entry carries its key, its inputs' hash, its outcome and its timing, and every error is in the program's own coordinates. A run can be simulated with a scripted handler and dry-run to a plan before it touches an agent. The simulator is discrete-event: timed effects park at their wake times and are delivered in wake order on one virtual clock, so concurrent branches accumulate the durations they wrote and a simulated race is decided by the same rule a live handler produces (least recorded clock, ties by declaration order). A sleep("1m") arm beats a sleep("1h") arm whatever their declaration order. Behind the worker bridge the simulator delivers its next wake only after the thread reports it has reacted to the last one, so a bridged simulation settles a race the same way, also when the dry run's recorder wraps the simulator.

waitUntil observations and ordinary effects use the same dispatch-failure recorder. A handler refusal records refused and holds the run for a capable host. Cancellation records cancelled. Other handler failures record their language code, or L4000 when none is supplied, with their message, recordable detail and stack when available. A journal append rejection during dispatch leaves the entry pending and exits without recording a handler failure.

Full rules, with every code: spec/cotal-lang.md.

Reusable helpers

A function call does not create a journal namespace. Repeated calls to a helper share the occurrence counters of its named steps, so inserting an earlier call renumbers the later ones. Wrap each instance in a distinctly named, single-branch parallel, with a record branch such as { call: () => helper() }. Keep the helper's required step names literal, and read the returned value from .call. Each wrapper adds one journal entry; repeating its name still increments its own occurrence. The worked example shows the resulting keys and the migration limits. No new primitive or computed required name is needed.

Continuing a run

Resume is re-execution: the driver replays the journal, the program runs from the top, recorded steps return instantly, and the first unrecorded step is performed live. It refuses a journal that belongs to another run, a pin that differs from the recorded ones, and a different language version.

A failed journal entry replays its error, while a pending entry lets the handler recover the external work it bound before the interruption.

A deadline that passes during authorized arming, before its pause is minted, ends the step as its expiry would. A relative sleep completes, and a checkpoint expires for its onExpiry to act on. A wait on a channel resolves null at its timeout and returns the event at the end of an idle window, after it opens the channel under the run's read admission. A waitUntil observes again while its deadline allows. None of them creates a past-due checkpoint. An existing pause keeps its recorded deadline and settlement, and cancellation still rejects the step.

A step that writes to a far side that honors no idempotency key (posting a comment, sending a mail) belongs in once. A resume that finds a step inside once begun and never settled does not dispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded request id, whose prompt names that id. Answer it with cotal run answer <run> <step-key> --value <json>, and the value becomes the step's result. An expired hold fails the step with the catchable L4027. Only ask runs inside once, so wrap just the step that writes: a host restart while it is in flight costs a settle. run validate refuses another effect in the body, or in a function the body calls by name, with L2033, and the run refuses one it reaches any other way with L4028.

const publisher = await spawn("publisher")
const res = await once(async () => {
  return await ask(publisher, { name: "publish", schema: { commentId: "number" } })
}, { name: "publish-360" })

Migrate moves a run onto edited source. A dry walk of the new program over the recorded journal finds every recorded step the edit changed (a divergence) and every one it no longer reaches (an orphan), and the orphan table says what each means: a removed sleep is nothing, a removed turn already happened, a removed spawn is a live agent you must adopt or release, a removed resolved checkpoint is a human decision you must explicitly discard. The decision is filed as a migration record with the actor's name on it. An adopted seat (--adopt <name>#<uid>) goes to the edited program's next spawn of that persona, which returns the recorded handle and mints nothing, so the agent keeps its identity, its worktree and its turn history across the edit. A released seat (--release <name>#<uid>) is despawned when the migration commits, through the same discharge a cancelled branch's seat leaves by, so the record never claims a release nothing did. The spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step reads the same seat back and a cancellation of it despawns the seat it holds.

Fork starts a new run from a named step of an old one, copying the prefix under the parent's pins (seed included, so the copied history's pure draws are the same draws). The child is a new run under a new id whose record names the parent and the cut step (forkedFrom); the parent is untouched. A spawn inside the copied prefix is honoured by its onFork: "adopt" copies it, and the child shares the parent's agent (the manager shows that seat one turn at a time across both runs); "respawn", the default, would mint a fresh identity the copied turns do not address, so this host refuses that cut (L5019) rather than rewriting the parent's history.

Fork planning and migration inspection use the recorded language version. Version-1 history uses the interpreter. Version-2 history is inspected inside a locked-down worker with a read-only journal, without a live effect handler or durable store. Inspection stops before the fork's cut step or any effect that needs new work. Program catch and finally blocks cannot extend the cut. The recorded pins are preserved.

Restore brings a run back from a full backup (cotal backup create) of a static-auth or open mesh, which holds each run's journal, records and admission. A run that waited at a checkpoint or a timer when the backup was made is back after a full restore, and the manager takes it over as after a restart. What the run did after the backup is not in its record. A user-mode backup, and one made before full backups held workflow runs, holds none (CLI reference). After a restore from one of them, or a registry-only restore, the store the restore set aside holds the only copy of the runs, and cotal backup cleanup-fallback deletes it only with --discard-history. After a full restore of a backup made before the space last ran, that store holds the only copy of what the runs did after the backup, under the same rule. The restore names each run it rolled back and each checkpoint answered after the backup that is open again, and that checkpoint accepts an answer again.

Wire records

The run's wire footprint is SPEC §14:

ThingWhereWhat it is
the runrun.<endpoint>.<runId> recordthe resolved pins (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and journalHigh on the status half
the programprogram.<endpoint>.<runId> recordthe source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against
the step journalWFJ_<space> stream, one subject per runappend-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate
a checkpoint answeranswer.<endpoint>.<token>.<answerId>the payload beside the one-use settle fact; the settle names the answer it accepted
a noticenotice.<endpoint>.<runId>.<addresseeId>.<noticeId>one bounded decision told to one agent, rendered ahead of its next turn
a migrationmigration.<endpoint>.<runId>.<migrationId>the report and who applied it, keyed by the report's own digest
the admissionadmission.v1.<endpoint>.<runId> in cotal_admission_<space>the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key
a revocationrevoked.v1.<endpoint>.<runId> in the same storewho revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect

The implementation writes the program record, and SPEC §14 refers to it, but the record-kind table in SPEC §13.7 does not list it. That table omission remains a specification gap; the table above describes the implemented storage layout.

The driver writes journalHigh at activation and again after each journal append, before the program acts on the entry. A successor whose replay ends below it refuses the run with RunJournalTailTruncated. That holds for records appended since the last activation too.

Host authority

A run's driver connects on a run-driver credential minted for one run and takeover attempt. It can append to its journal, use its replay durable, and write its own run, program, notice and migration records. It has no store point reads, checkpoint writes, chat consumers, or channel and membership registry grants.

The hosting process keeps a separate run-mediator connection for effects and reads. The driver receives methods and data from that host; it never receives the mediator credential or connection. The host checks the journal's current activation and step identity before dispatch, and checks pause and wait authority again at each operation. Cancellation cleanup also admits losing steps named by a settled parent whose cancel.issued is still false. That permission allows cleanup; it cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery receipts. A recorded match can be reread only at its bound sequence and channel. A conclave's recorded ownership flag must match its step-derived channel before registry writes or cleanup.

The mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted host authority. Record reads exposed to the driver are restricted to its own run's keys. Reads that decide writes remain leader-served. A read of the journal uses the run's filtered replay durable, including the diagnostic for a journal with no run record. That durable is named after the takeover, and an attempt reads it many times, so reads under one takeover run one at a time in the hosting process and a replay removes a durable of its own name that an interrupted earlier read left behind. A create failure permits this recovery only for a structured JetStream API error with code 10148. Other errors propagate without deleting the durable, even if they carry that numeric or string code. A durable that survives a replay's own delete belongs to a reader the process cannot account for, and reading its tail is refused. A drive handles that refusal as a takeover does: the reads behind its steps, and the diagnostic for a journal with no run record, replay up to three times before the refusal is raised. An operator read runs under a takeover minted for that read and reports the refusal on its first read.

A served read uses a one-shot run-operator credential. An answer uses a read to find the open pause, then a second credential pinned to that token for the answer and settlement. cotal run --local uses the same driver/mediator split. On an authenticated mesh it needs the locally recorded space signer to mint both credentials; a single --creds file is refused. Direct library users supplying broker clients to MeshHandler are constructing a trusted effect host. A hosted driver receives its closed effect interface instead.

This split confines broker credentials; it is not process isolation for injected host code. The runtime and its effect host share the manager process. A run's channel reach is the admitted ceiling (SPEC §14.8): the starting caller's issued channel scope, recorded once in cotal_admission_<space> under a per-run run-admitter credential the driver never holds, and re-read by the host before every channel effect. Spawn and turn keep their own delegated checks; notify writes agent-addressed notices and is not channel publication. Treat run as program-execution authority bounded by that ceiling, not as sandboxing of the program.

A version-1 fork can replay its settled parent history through the host. Inherited checkpoint identifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use child-derived identifiers. A fork is a new run and takes a new admission under the caller who forks it; the parent's ceiling is not inherited.

Available implementations

The language, its validator, interpreter, simulator and dry run are @cotal-ai/lang (packages/lang), usable in-process with your own effect handler and with no broker: validate(src), then run(src, { runId, handler }), and resume(src, journal, { runId, pins, handler }) to pick a run up from its journal (the package README has the snippet, with SimHandler as the handler). That is the in-process route, yours to drive with your own handler; a run the driver starts executes on the compiled engine, as the engine paragraph below says. The wire substrate of §14 (the WFJ_<space> stream, the five record kinds, the activation barrier, the per-run grants) is in @cotal-ai/core, and the run driver, journal store, migrate and fork are @cotal-ai/runtime (implementations/runtime). The migrate check is reachable as cotal run check <runId> <program>; committing a migration it judged admissible is not reachable from any surface yet, and neither is a fork: planFork and commitFork are library exports of @cotal-ai/runtime with no CLI or MCP verb. On the mesh handler, sleep, checkpoint, wait(message(...)), wait(idle(...)), wait(down(...)), wait(replied(...)), waitUntil, notify, spawn, conclave, ask, monitor and turn are durable.

Mesh effect support

Every effect the language defines performs on the mesh handler; nothing is refused as not-yet-durable any more. The operator surface over the driver is cotal run; the section above has the verbs.

Engine limits

Two engines, and which one runs your program. The tree-walker is language version 1 and the compiled engine is version 2, two languages rather than two speeds of one (spec/cotal-lang.md §8.4 lists what differs). The driver hosts both: every run a driver starts is stamped 2 and executed by the compiled engine. The program runs in its own locked-down worker thread with nothing in its global scope, while the effects and the durable journal stay in the driver's process, bridged over a message port. No socket or credential enters the isolate holding the program, and every version-1 record keeps replaying on the walker, which is the walker's job. On either engine the driver bounds an effect's ok result at the broker's max_payload less 4096 bytes: a larger result is refused ahead of the settling append (L5006), the step stays pending, and the run is released. The driver serves a declared set of versions, and a record whose version it does not serve is refused by name (L5023) with the run left untouched, instead of being replayed by whichever engine happens to be present. Records do not cross between versions in either direction; the repair is to resume on the recorded version, or to fork.

The engine needs node 22 or newer and refuses below it as EngineUnavailable, which is an implementation limit and not a language error: it carries no L code, so there is nothing to look up in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on AsyncLocalStorage, and 22 is the lowest node it has been measured on. The walker has no such floor.

Foreign error text

Worker execution, read-only inspection and the host effect bridge use the same name and message projection for plain failures. A string name or message is retained, including an empty string; this also applies to writable fields on native Error objects. Other names become Error. A value without a readable string message uses its normal string conversion. If that conversion also throws, the message is <unprintable thrown value>, not a successful result or a replacement exception that strands the answer. Native typed failure classes keep their existing classification. This contains exceptions during name/message formatting; it is not a time limit for arbitrary accessor or conversion code.

On this page