Skip to Content
0%

Agent Script: The Control Plane for Agentic Decisions

agent and human with laptop collaborating on code

A language built for humans and the coding agents that help them

You’re two weeks from shipping an agent to production and you’re staring at the same question you’ve been circling for a month: how much of this should I let the model decide?

Not whether to use a model at all. That decision’s already been made. But which decisions the model gets to make.

The one where a customer verifies their identity? Probably not that one. The one where the agent picks how to phrase a confirmation? Sure. The one where it decides whether this conversation is a refund question or a shipping question? Depends on your risk tolerance, your audit requirements, and how much the frontline team trusts the model.

Most agent frameworks don’t let you choose how much the model decides on a per- decision basis. You pick a posture at the framework level: deterministic workflow engine or probabilistic LLM loop. That is the wrong unit of control.

Agent Script is a single-file, declarative language for defining AI agent behavior. It’s the control plane for the decisions an agent makes on the way to completing a task. Salesforce open-sourced it in April 2026 at github.com/salesforce/agentscript.

Agent Script lets you set control per decision. You pin the load-bearing logic that must be deterministic and let the agent reason through the rest, in one file, without switching tools.

What Agent Script Is

Agent Script is a single-file, indentation-sensitive language for agent behavior. It looks a little like YAML and a little like Python: you declare variables, define actions the agent can call, and write the logic that runs around those actions.

A minimal agent:

system:
   instructions: "You help customers with their orders."
variables:
   customer_name: mutable string = ""
       description: "Customer's name, once we know it"
   customer_verified: mutable boolean = False
       description: "Whether the customer has been identity-verified"

start_agent order_helper:
   description: "Helps verified customers with order questions"
   reasoning:
       instructions: ->
           | Help the customer with their order.
           if @variables.customer_verified:
               | You're talking to {! @variables.customer_name }.
               | You can discuss account-level details.
           else:
               | Ask the customer to verify their identity first.

The instructions block is doing two things at once: it’s using if/else to conditionally build the prompt, and it’s interpolating a variable ({! @variables.customer_name }) into the rendered text. The conditional re-evaluates every iteration of the reasoning loop, so the moment verification succeeds, the next iteration sees the “verified” prompt.

One file. Diffable, reviewable, testable, shippable through the same CI pipeline as the rest of your code.

Agent Script compiles to an executable specification that runs on a Salesforce-managed runtime. The compiler, linter, language server, VS Code extension, and playground are in the open-source repo. The runtime is not — more on that below.

How a Turn Executes

An agent turn in Agent Script is a reasoning loop. The model reads the instructions you wrote, looks at the actions currently available to it, and picks one — or decides it’s done and responds to the user. When it picks an action, the runtime executes it, variables update, and the loop iterates. The model reads the instructions again (now reflecting the new state), looks at the actions now available (some may have just become available, some may have just disappeared), and makes the next choice. When it generates a response instead of an action call, the loop exits.

Two things are worth underlining.

instructions and available when re-evaluate every iteration. That’s not an optimization detail — it’s the mechanism. Here’s an example of why this matters:

When you write if @variables.customer_verified: inside instructions, the prompt the model sees on iteration 2 is different from the prompt it saw on iteration 1, because the condition flipped when verification succeeded. When you put available when @variables.customer_verified on an action, that action wasn’t in the model’s toolbox on iteration 1 and it is on iteration 2. You don’t need a separate pre-pass to shape what the model sees; the loop shapes it for you, every turn.

The model sees the result, not the reasoning. Branches that were skipped, actions gated behind conditions that were false, parameters you bound from variables — none of those are in the prompt. The model sees the rendered instructions and the currently-available action pool. Everything else already happened.

The seam between “already decided” and “left open” is something you move by what you author. Pin more, the agent is more scripted. Pin less, the agent is more agentic. Same language, same file, same runtime. Moving the seam is a code change, not a framework change.

The Primitives

A quick tour. Each primitive is a way to pin a different kind of decision.

variables: — declare typed state up front. Every variable has a type, a default, and a description. The description is not cosmetic: the model reads it when it needs to fill that variable from conversation context.

variables:
   order_number: mutable string = ""
       description: "Order number provided by the customer"

set — inside a procedure, assign a variable. Often used in an action callback to capture outputs:

verify: @actions.Verify_Customer
   with email=@variables.customer_email
   set @variables.customer_verified = @outputs.customer_found
   set @variables.customer_id = @outputs.customer_id

with input=value — bind a parameter on an action call. It works the same way whether the action is invoked deterministically or as a reasoning action the model can choose. Each parameter is either bound to a concrete value you control, or to ... — the ellipsis — which tells the model to fill it in from conversation. A single call can mix both side by side:

issue_return: @actions.Issue_Return
   with order_number=@variables.order_number  # you decide
   with return_reason=...                     # model decides

| in reasoning.instructions — each | line appends to the string the model sees as guidance. if/else inside the instructions builds the prompt dynamically. Merge fields like {! @variables.x } interpolate values, and {! @actions.name } references an action by name — a direct way to tell the model which tool to reach for.

instructions: ->
   | Help the customer with their order.
   if @variables.verified:
       | Their order ID is {! @variables.order_id }.
       | Use {! @actions.find_order } to look up the latest details.

if / else — branch deterministically inside instructions to shape the prompt, or inside other procedures to gate logic. Re-evaluated every iteration, so the prompt evolves as state evolves.

available when — a guard clause on a reasoning action. When the condition is false, the action is not presented to the model on that iteration. It’s not disabled; it’s not in the set of things the model can choose from. Re-evaluated every iteration, so actions unlock (and lock) as state changes.

issue_return: @actions.Issue_Return
   available when @variables.return_eligible == True
   with order_number=@variables.order_number

subagent + transition to — organize capability into focused units, and route between them. Exposed as a model-drivable transition in reasoning.actions via @utils.transition, with available when controlling when a transition is offered.

That’s the working set. Every primitive above is a way of saying “I’ll decide this part — you handle the rest.”

One Agent, Three Postures

The rest of the post rests on this example, so it’s worth looking at closely. One scenario, three versions, same grammar — the only thing that moves is how many decisions the author pins.

The scenario: a customer wants to return an item. Three actions — verify by email, find the order, issue the return — and a handful of variables. The shared setup is identical across all three versions; only the amount of authored control changes.

system:
   instructions: "You help customers process returns."
variables:
   customer_email:     mutable string = ""
   customer_id:        mutable string = ""
   customer_verified:  mutable boolean = False
   order_number:       mutable string = ""
   order_found:        mutable boolean = False
   return_eligible:    mutable boolean = False
   rma_number:         mutable string = ""

Actions are declared with inputs, outputs, and a target that points at the underlying implementation — an Apex class, a Flow, an external service. For brevity we’ll assume Verify_Customer, Find_Order, and Issue_Return are declared on every posture.

Posture 1 — Fully scripted

Every decision is authored. The flow is a strict sequence — verify, then find, then issue — enforced by chained available when gates. At each point, the model has exactly one action it can reach for, and the instructions tell it which one.

start_agent returns_scripted:
   reasoning:
       instructions: ->
           if not @variables.customer_verified:
               | Ask the customer for the email they used when placing the order.
               | Capture it with {! @actions.capture_email }.
           if @variables.customer_verified and not @variables.order_found:
               | Ask the customer for their order number.
               | Capture it with {! @actions.capture_order }.
           if @variables.order_found and @variables.return_eligible and @variables.rma_number == "":
               | Confirm the customer wants to return this order,
               | then run {! @actions.issue_return }.
           if @variables.rma_number != "":
               | Tell the customer the return is authorized.
               | RMA: {! @variables.rma_number }.
           if @variables.order_found and not @variables.return_eligible:
               | Tell the customer this order is not eligible.
       actions:
           capture_email: @utils.setVariables
               available when @variables.customer_verified is not True
               with customer_email=...
           verify: @actions.Verify_Customer
               available when @variables.customer_verified is not True and @variables.customer_email != ""
               with email=@variables.customer_email
               set @variables.customer_verified = @outputs.customer_found
               set @variables.customer_id = @outputs.customer_id
           capture_order: @utils.setVariables
               available when @variables.customer_verified and @variables.order_found is not True
               with order_number=...
           find_order: @actions.Find_Order
               available when @variables.customer_verified and @variables.order_found is not True and @variables.order_number != ""
               with order_number=@variables.order_number
               with customer_id=@variables.customer_id
               set @variables.order_found = @outputs.order_found
               set @variables.return_eligible = @outputs.return_eligible
           issue_return: @actions.Issue_Return
               available when @variables.order_found and @variables.return_eligible and @variables.rma_number == ""
               with order_number=@variables.order_number
               with customer_id=@variables.customer_id
               set @variables.rma_number = @outputs.rma_number

The state machine lives entirely in the available when clauses and the conditional instructions. Each step unlocks when its preconditions are met; the instructions name the action the model should reach for next. The only thing left for the model is slot-filling — turning what the customer said into typed variables — and natural-language rendering. This is a workflow with an LLM on top.

When you’d reach for this: regulated flows, anything auditable, the parts of your agent where the ops team needs to trace exactly what happened and why.

Posture 2 — Mixed

Keep the verification invariant, but let the model drive resolution. Verification is itself a reasoning action — gated so it’s the only one available while the customer is unverified. The model has no real choice but to run it.

start_agent returns_mixed:
   reasoning:
       instructions: ->
           if not @variables.customer_verified:
               | Ask the customer for the email they used when placing the order,
               | then verify them with {! @actions.verify }.
           else:
               | Help the customer with their return.
               | Use {! @actions.find_order } to look up their order.
               | If it's eligible, issue an RMA with {! @actions.issue_return }.
       actions:
           capture_info: @utils.setVariables
               with customer_email=...
               with order_number=...
           verify: @actions.Verify_Customer
               available when @variables.customer_verified is not True and @variables.customer_email != ""
               with email=@variables.customer_email
               set @variables.customer_verified = @outputs.customer_found
               set @variables.customer_id = @outputs.customer_id
           find_order: @actions.Find_Order
               available when @variables.customer_verified == True
               with order_number=@variables.order_number
               with customer_id=@variables.customer_id
               set @variables.order_found = @outputs.order_found
               set @variables.return_eligible = @outputs.return_eligible
           issue_return: @actions.Issue_Return
               available when @variables.return_eligible == True
               with order_number=@variables.order_number
               with customer_id=@variables.customer_id
               set @variables.rma_number = @outputs.rma_number

Identity is too load-bearing to leave open, so verification stays gated — but it happens inside the reasoning loop, where the model encounters it naturally. The other available when clauses reflect real preconditions: no order lookups before verification, no returns issued before eligibility. Customer IDs still come from variables; the return itself still has its parameters pinned.

In practice, this is what most production agents look like: a deterministic floor on the sensitive decisions, freedom to reason through the middle.

Posture 3 — Agentic

Open up parameter extraction. Let the model drive timing, sequencing, and all inputs. The available when clauses are the only invariants left.

start_agent returns_agentic:
   reasoning:
       instructions: ->
           | Help the customer with their return.
           | Verify them with {! @actions.verify },
           | then use {! @actions.find_order } to locate their order.
           | If it's eligible, issue an RMA with {! @actions.issue_return }.
           | If the customer asks for a human, {! @actions.escalate }.
       actions:
           verify: @actions.Verify_Customer
               with email=...
               set @variables.customer_verified = @outputs.customer_found
               set @variables.customer_id = @outputs.customer_id
           find_order: @actions.Find_Order
               available when @variables.customer_verified == True
               with order_number=...
               with customer_id=@variables.customer_id
               set @variables.order_found = @outputs.order_found
               set @variables.return_eligible = @outputs.return_eligible
           issue_return: @actions.Issue_Return
               available when @variables.return_eligible == True
               with order_number=...
               with customer_id=@variables.customer_id
               set @variables.rma_number = @outputs.rma_number
           escalate: @utils.escalate

All input parameters are ... except customer_id, which can only be set by the verification action we control. No order lookups before verification, no returns issued before eligibility — and inside those gates, the model picks actions, extracts parameters, decides order, and decides when to finish.

This is an agent loop — with a guardrail pattern that’s visible in source and testable without a model call. Check that the available when conditions are wired correctly and you’ve checked the invariants.

What just happened

Same file format. Same grammar. Same runtime. Same two primitives doing the heavy lifting — available when and conditional instructions. Three postures.

We didn’t switch frameworks to move between them. We changed which decisions were authored. That is what “scales from scripted to agentic” actually looks like in source code — not a migration path between two systems, but a dial on one. Teams that start scripted can loosen a gate or open up a parameter to .... Teams that start agentic can add a gate or pin a parameter when they discover a decision is load-bearing. The artifact is the same artifact throughout.

Where It Fits in the Stack

Agent Script doesn’t replace your deterministic code. It orchestrates it.

Apex Flow Agent Script
Paradigm Imperative, OOP Declarative, visual Declarative, schema-driven
Primary use Business logic, integrations Automation, approvals Agent behavior
AI integration Manual (call LLM APIs) Limited (invocable actions) Native — model reasoning drives the execution loop
Determinism Fully deterministic Fully deterministic Hybrid: per-decision

Apex classes and Flows show up in Agent Script as actions, referenced by a target URI like "flow://Verify_Customer" or "apex://ReturnsService". Agent Script decides when and why an action runs. Apex and Flow define how. You don’t rewrite your existing business logic to put an agent on top of it; you describe the agent that uses it.

Agent Script Is the API

Every tool that works with an Agentforce agent works with the same file. The Builder, Labs, Vibes — all views over the .agent file. The compiler emits a runtime spec from it, the linter validates it, the language server powers every editor that reads it. One artifact, many surfaces.

Your agent is a file. That file is version-controlled, diffable, lintable, compilable, and — crucially — writable by anything that can write text. That includes you, a teammate, a CI pipeline generating from a template, and, increasingly, coding agents (Claude Code, Codex, Vibes, take your pick).

That last point is where this gets interesting. Coding agents write good Agent Script. Describe what the agent should do, get a valid .agent file back, run it against the playground, watch what happens, describe the fix, go again. That’s the loop you already have for every other kind of code: describe, generate, run, inspect, revise. Agents are hard to get right on the first try — the prompt is a function of the state, the available actions are a function of the state, the state is a function of the conversation. You iterate. Iteration is cheap when the artifact is a file.

The agent you ship is the file you review. No hidden assembly step, no runtime stitching a prompt together from fragments nobody on the team can see.

This is why we open-sourced Agent Script. Coding agents write the code they’ve seen, against the tools they have in context. If the grammar, linter, compiler, LSP, and formal spec aren’t public, a coding agent has to guess — and the guesses compound into files that look plausible and fail at compile time. Putting all of it in a public repo means any coding agent — ours or anyone else’s — can read the spec, validate against the linter, and produce files a human can review and ship. The review surface is a diff, not a screen recording.

Agentforce Builder is still there, and for most teams most of the time, that’s where agent development will happen. Builder is one view over the spec — canvas and script editor over the same file. Your IDE (with the VS Code extension from the repo) is another view. A coding agent is another. Your CI pipeline is another. They all talk to the same artifact.

What the Runtime Runs

Agent Script is not interpreted at conversation time, and it is not assembled by the model at runtime. The compiler takes your script and emits an executable specification — a graph of nodes with fixed topology, known state transitions, and explicit tool wiring.

Actions in that graph resolve through a pluggable set of target URI schemes — flow:// for Salesforce Flow, apex:// for Apex classes, and others defined per dialect. The scheme determines how the runtime dispatches the call; the action declaration in your script determines when and with what arguments.

The execution topology of your agent is known before any conversation begins. You can lint it, validate it, test its deterministic paths without an LLM call, and review its diff in a pull request.

Authority Is Authored

You decide what to pin and what to leave open. Every set, every literal with binding, every available when gate is a decision you already made. The model never sees those choices. Everything you didn’t pin, the model decides at conversation time. An … parameter, an unguarded transition, a condition you didn’t write. Pin more, the model has less room. Pin less, it has more. That’s per-decision control, in one file, without switching frameworks.

The script caps what the model can reach. Regardless of how much freedom you give within a decision, the model can only reach the actions, variables, and transitions you declared in the .agent file. It can’t invent new tools, call APIs you didn’t wire up, or access capabilities you didn’t author. The ceiling is always explicit.

On Agentforce, the platform adds another layer. Every action an Agentforce agent executes runs in the context of an assigned user with a specific permission set. Object-level, field-level, and record-level security all apply. If the user can’t access a record, neither can the agent. The same sharing model that governs human access governs agent access.

Three layers, each one narrowing what the agent can do, each one visible before any conversation begins. Your authored decisions, your declared capabilities, your platform permissions. No separate guardrail layer bolted on top.

Practical Advice

Identify the load-bearing decisions. Authentication, money, routing to sensitive queues, anything a compliance or legal team will ask about. Pin those. Everything else starts open.

Let the model be agentic where it doesn’t hurt. Phrasing, ordering, picking among equivalent paths, extracting parameters from free-form conversation. These are things models do well. Don’t take them away reflexively.

Test your gates without calling the model. available when conditions and instructions branches are deterministic — given a state vector, you know exactly which actions the model will be offered and exactly what prompt it will see. You can unit-test that the verification gate closes the order-lookup action when customer_verified is false, without ever calling an LLM. Your model test surface is much smaller than your agent’s total surface.

What’s Open, What Isn’t

What’s open: the specification, the toolchain, and the developer tools — parser, linter, compiler (targeting the Salesforce runtime specification), language server, VS Code and Monaco integrations, and the UI playground. Apache 2.0. Contributions are welcome on all of them.

What requires Salesforce: the runtime. Agent Script compiles to a Salesforce-internal specification format that executes on Salesforce infrastructure. You can parse, lint, compile, and build tooling around Agent Script with everything in the repo. Running agents requires Salesforce’s runtime environment. More will open up, but we’re not promising a schedule at this time.

One consequence of that tradeoff: we’re not accepting changes to the language spec right now. The spec has to stay in sync with the runtime, and until there’s a path to open-sourcing the runtime, unilateral spec changes would create a split we can’t support. Tooling PRs, bug fixes, and editor integrations are all fair game.

The language is dialect-aware. A base dialect (agentscript) defines the grammar and block types. Agentforce and Mulesoft Agent Fabric extend it — each adds its own block types, field schemas, and lint rules. Parser and linter select the dialect per file. Other dialects can be added the same way.

The README says it plainly: “Agent Script does not prescribe how much control you take. The language gives you the tools — how you use them is up to you.”

Closing

The question every team building agents thinks they have to answer, scripted or agentic, was the wrong question. The right one is whether your language lets you set control per decision and change your mind later without rewriting.

Agent Script’s answer is yes. You pin what has to be pinned, open what can be opened, and change your mind in a pull request instead of a migration plan. The agents you build next year will need different seams than the ones you’re building this month. The file is what lets you move them.

If you’ve been stuck behind a UI, try looking at the file the UI was writing for you. The repo is at github.com/salesforce/agentscript. The playground is one pnpm ui:dev away. Point your favorite coding agent at the spec and see how far one file will take you.

Get the latest articles in your inbox.