Scripts
A script is the agent's plan as an artifact. Instead of one tool call at a time, the agent emits a list of ops with explicit data flow. Because the plan is a value, you can read it before it runs and re-read it after.
The agent emits the whole plan at once
One run_program call carries the full sequence. Ops run in order, each result can be named, and the runner reports back what completed, so the model never repeats a write that already happened.
run_program({
program: [
{ op: "github_list_prs", id: "prs", params: { repo: "$repo" } },
{ op: "core.let", id: "open", params: { be: "prs.filter(p, p.state == 'open')" } }
],
inputs: { repo: "acme/api" }
})
Every op has the same shape: op (the tool), an optional id (bind the result to a name), params, and optional onError and retry policies.
Data flows through $-refs
An op's id binds its output; later ops reference it in any string value, like "$prs" or "${prs[0].title}", and the runner substitutes before dispatch. For reshaping, two core ops do the work: core.let binds a CEL expression, and core.transform runs a jq filter over any binding to group, reduce, slice, or build new objects.
Control flow is part of the language
core.if: a CEL condition withthen/elseregions.core.map: run a body over every item, withmaxConcurrencyand per-item error policy.flow.parallel: independent branches at once,failFastoptional.- Per-op
onError(halt,continue, or a default value) andretrywith backoff.
The plan carries its own branching, so a conditional runs without a second round-trip to the model.
Functions have inputs and sealed scopes
A script with inputs runs sealed: the body sees only its declared inputs, the job's history, and names bound by its own ops. That's what makes it a function: same body, different inputs, no accidental dependence on conversation state. A bare script (no inputs) sees cross-turn bindings normally, which is the convenient form for one-shots.
Every run leaves a trace
Each tool op dispatches through the normal call path, so a script run lands in the job's trace like any other work: every op a span, every cost counted, every write gated by approvals.
An op can be addressed to a person
ask takes a to and puts the question to someone by name. The op suspends, the ops that depend on it wait, and everything independent of it keeps running.
{ op: "ask", id: "scope", to: "ana@acme.de",
params: { question: "Extend the pilot to include S/4 writes?",
choices: ["approve", "decline"] } }
Ana answers wherever she is: a card in the scene, a push on her phone, a reply in the channel her workspace uses. Everyone else in the job sees one line, waiting on Ana, and since when. $scope binds to what she picked, so the branch below it is ordinary control flow.
say is the same shape without the wait. Addressed to a person, it delivers a message; with no to, it posts to everyone reading the job. A say never blocks, which is what makes it usable for talk rather than for coordination.
The to accepts a person, a role, or another agent. An agent addressed by ask answers by running, which is why prompting the agent and asking a colleague are the same op with different addressees.
A done is a claim until something settles it
An answer records what the actor said, not what is true. That holds for a person tapping done, and it holds for a callback from a system you wired in. What separates them is how much checking the op asks for.
expect names the evidence and the window:
{ op: "ask", id: "swap", to: "jonas@acme.de",
params: { question: "Replace the filter on line 2" } },
{ op: "expect", id: "seated", after: "swap",
params: { evidence: "camera.line_2", check: "new filter seated, housing closed",
within: "10m" } }
The claim and its evidence are two ops, so they can disagree. When they agree the run continues with both on record. When they disagree, or when the window passes with nothing arriving, the expectation misses.
Expectations settle over days
An expect op holds a window rather than a call, so a script can state what should happen next and wait for the world. A five-step onboarding writes all five ops on day one: the welcome that goes out this morning, and the check-in that resolves in two weeks.
{ op: "expect", id: "signin",
params: { evidence: "auth.first_signin", within: "3d", onMiss: "replan" } }
Pending ops are rows in the job, not a process parked in memory, so a run holding a fourteen-day window costs nothing while it waits and stays readable the whole time. Open the job and it shows three tenses at once: what happened, what is waiting on whom, and what the script does next.
A miss follows the branch you wrote
onMiss names what happens when the window passes, next to the op it belongs to. It takes the same regions as core.if, so the branch is ordinary control flow that was written before the run started:
{ op: "expect", id: "signin",
params: { evidence: "auth.first_signin", within: "3d" },
onMiss: [
{ op: "say", to: "$customer.email", params: { body: "$templates.nudge" } },
{ op: "expect", id: "signin_2", params: { evidence: "auth.first_signin", within: "3d",
onMiss: [{ op: "ask", to: "$owner" }] } }
] }
Read the script and you know what a miss will do before it happens, which is the property that makes a run repeatable: the same script meets the same miss with the same branch.
onMiss: "replan" is the deliberate exception. It hands the agent the overdue expectation along with the rest of the plan and lets it edit the pending ops, for the cases where the right move depends on why the miss happened. What it changes lands in the job's history with its reason and its author, and past ops are untouched, because the plan and the record are the same list.
Retry policy stays available for the mechanical cases (onError, retry with backoff).
People edit a running script the same way. An ask can only be answered by the person it names; handing it to someone else rewrites the op's to, and the step says so.
What's next
- Traces: the record a run leaves.
- Jobs: where scripts execute.
- Approvals: the gate a consequential op waits on.
- Assets: what scripts act on and produce.
Updated 2026-08-15