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.

Run a saved program in your scene

A program cell shows the steps of a saved SceneScript plan alongside its live execution. A job creates the plan; Run program starts it. Each run records its own inputs, events, approvals and results. Execution runs on the Daslab server, so you can close the app while it waits and return to review the next action. Saving or opening the cell does not start a run.

Try the one-minute example

Open a job in your scene and paste:

Create a live program cell called “One-minute workflow” using daslab_program_create with preset: "minute". Leave it ready for me to run and show me where the result will appear.

Then open the new cell:

  1. Press Run program, then Send sample reply within 45 seconds.
  2. Edit Draft to approve and press Approve & continue.
  3. Open Your approved quote. It contains the text you approved.
  4. Return to the program. Let the receipt wait expire after about ten seconds, or press Send sample receipt to finish through the other branch.

The reply is sample data. Waiting, approval, the note write and the timeout are real. No email is sent and this example makes no model calls while it runs. The job that builds it can still use a model. If you miss the first deadline, nothing is written; press Run again to try again.

A preset is a built-in recipe for an ordinary editable program. minute creates this exercise. email-quote creates an email follow-up using a Gmail account, a recipient and a quote template you supply, with review before each send. A custom definition lets a job write other sequences with the same SceneScript primitives.

Let a browser request supply the draft

Ask a job in your scene:

Create a live program cell called “Browser quote demo” and a result note called “Your approved quote”. Add a native http/endpoint cell with daslab_add_cell, then get its URL and stream with endpoint_list. Save a custom definition with daslab_program_create: compute a browser URL with this run’s run_id and query parameters from, msg, and price; show the URL in the program cell; use await_event to wait up to five minutes for a GET matching that run_id; draft a quote from the received query parameters; require review of daslab_update_asset before saving to the result note. If the wait expires, finish without writing. Leave it ready for me to run.

Press Run program, then Open in browser ↗ in the waiting step. The new tab acknowledges the request. Return to the program cell: the incoming values are now a draft you can edit and approve. The five-minute window is a deadline, not a delay. You can complete the example in under a minute.

To supply different values, copy the generated URL and edit from, msg, and price before opening it. Keep run_id unchanged. Encode spaces as + or %20; for example, price=CHF+2%2C400 arrives as CHF 2,400. Use the link from the current run, since each new run has a different ID.

StepWhat it does
Compute the URLcore.let builds a link containing this run's ID
Receive the requestThe endpoint records its method and query parameters
Resume the programawait_event accepts the matching GET
Prepare the draftThe program reads reply.event.payload.query
Review and saveYour approved text is written to the scene note

Opening the URL resumes an existing wait. It does not start a new run. An acknowledgement means the endpoint recorded the request; it does not mean the draft has been approved. If the cell stays waiting, check that you opened the current run's link before its deadline.

Change the next run without losing the old one

Ask a job:

Update “Browser quote demo” in place with daslab_program_update. Keep its endpoint and result note. Change the wait to two minutes and add “Delivery: 5 working days” to the draft. Preserve the GET/run_id matching, timeout branch, and review before saving.

Each update creates a new saved revision. Existing runs keep the code and inputs they started with. Stop an active old run, then press Run again to use the new revision. Stopping does not undo completed writes. Use the run history selector and SceneScript · this run’s snapshot to inspect earlier runs. Editing a draft at an approval changes that action only; ask a job to update the program when you want a change to persist across runs.

For jobs authoring custom programs, definition is saved code. Its $refs belong to the future run; use normal $refs without dollar-sign escaping. Pass values from the authoring job through the separate inputs argument. Use review_tools to name tools that must wait for human review.

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 with then / else regions.
  • core.map: run a body over every item, with maxConcurrency and per-item error policy.
  • flow.parallel: independent branches at once, failFast optional.
  • Per-op onError (halt, continue, or a default value) and retry with 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.

What the world can be

evidence names a stream. Anything that arrives in a scene as an event can settle an expectation: a customer's reply on a connected messaging account, a webhook from a system you integrate, a clock, an endpoint the script mints for someone to call, or another script finishing. The op reads the same whichever it is, and the matching event becomes its value, addressed like any other result.

evidencesettles when
line:message (or any connected channel)the next message from that conversation arrives
<provider>:<event>a webhook from that integration lands in the scene
timerthe clock passes the window
httpsomething POSTs to the URL the op hands back before it waits
jobthe named script reaches its end

A booking script, read top to bottom. The customer tapped Book in the account's menu; the script sends the schedule, then waits for them:

[
  { op: "line_send", to: "$tap.conversation",
    params: { messages: [{ type: "card", title: "Today at CUBIC", buttons: "$schedule" }] } },
  { op: "expect", id: "pick",
    params: { evidence: "line:message", from: "$tap.conversation", within: "10m" },
    onMiss: [{ op: "line_send", to: "$tap.conversation",
               params: { messages: [{ type: "text", text: "Still want to book? Reply with a time." }] } }] },
  { op: "book_class", params: { slot: "$pick.event.text" } },
  { op: "line_send", to: "$tap.conversation",
    params: { messages: [{ type: "text", text: "Booked. See you at $pick.event.text." }] } }
]

The run pauses at pick for as long as the customer takes, and the job shows it waiting on that conversation. When the reply comes, the event is pick's value and the next op is ready; if ten minutes pass instead, the branch you wrote runs. Nothing about the script is different because its evidence came from a phone rather than a sign-in: a step addressed to a person and a step that waits for a message are the same shape of op, settled by whoever or whatever was named.

Every expectation carries a window. An op that waits for the world without one is rejected when the script is written, so a run can never hold an item open forever.

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-09-20