> ## Documentation Index
> Fetch the complete documentation index at: https://docs.asteroid.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Script a step

> Run a Playwright script on a node so a proven path repeats exactly, in seconds, for no model cost.

Give the node a **Script** and it runs a Playwright script first, before any model call. The same clicks happen every execution, in seconds, for no model cost.

<Card title="Let Astro script it" icon="wand-sparkles" href="/build/in-the-platform" horizontal>Ask Astro which nodes are suitable to script. It writes the script from what the workflow already did, attaches it to the node, and runs it.</Card>

<Tip>
  For all of the details about how scripts work and run, see the [Script runtime](/concepts/scripting-runtime) section.
</Tip>

***

## Why script a node?

An [agent node](/concepts/nodes) normally calls an AI model at each step to decide what to do. The model reads the page and picks each action from your instructions. That is useful when the page is very different each time, regularly changes or requires AI decision making.

It is often waste to always use an agent on a page that doesn't have these characteristics. In that case, you should convert the node into a script.

## When should I script a node?

Most nodes should eventually be scripted if you're working on a website, rather than a [Windows or Linux desktop](/concepts/browser-vs-computer-use).

To start with, it's convenient to have a node be agentic. This way, the agent can interact with the website for a few executions and you can find failure cases and improve your [instructions](/build/instructions).

After a few successful runs, it's a good time to start scripting the node.

[Astro](/build/in-the-platform) is able to do this for you, or you can do it from [your coding agent](/build/from-your-coding-agent). Astro or your agent might look at the previous [executions](/concepts/executions) or spin up a [live environment](/concepts/live-environments) in order to view the website in real time to understand how to script it effectively.

Once the scripts are written, ensure you run [test runs](/build/test-and-publish) with realistic data in order to identify failure modes of the new script.

<Tip>
  Time saving tip: a script can [route itself to the next node](#route-to-the-next-node-yourself). Use AI to do the routing only when deciding where to go next requires some intelligence and reasoning.
</Tip>

***

## How to add a script

Open the node's **Instructions** tab. The **Script** field sits at the top. Select a `.js` file from the node's [shared directory](/concepts/filesystem). The node becomes a scripted node, and an "If the script fails" picker appears beside the field. Remove the script and the node goes back to running turn by turn agentically.

Astro does the same thing from the chat. Ask it to script a node and it writes the file, sets the field, and picks a failure action.

[Nodes](/concepts/nodes) covers where a script file lives and how the runtime resolves its path.

***

## What happens when the script runs

The script runs against the live browser session. After the script finishes, the runtime picks the next node in this order.

| Order | Condition                                                             | What the runtime does                                |
| ----- | --------------------------------------------------------------------- | ---------------------------------------------------- |
| 1     | A selector transition matches right after the script                  | Take that transition. No model call.                 |
| 2     | The script returned a handoff to one of the node's AI transitions     | Take that transition. No model call.                 |
| 3     | The script succeeded and the node has exactly one outgoing transition | Take it. No model call.                              |
| 4     | None of the above                                                     | Call the model, with the script's output as context. |

The first three paths skip the model. Rows 1 and 3 are automatic. Row 2 is the one you drive from inside the script, and it is the only path that lets a script choose between several branches. See [Route to the next node yourself](#route-to-the-next-node-yourself).

***

## What the script returns

| Return value | Effect                                                              |
| ------------ | ------------------------------------------------------------------- |
| An object    | Each top-level key becomes an output variable for downstream nodes. |
| A string     | Wrapped under a single `script_output` variable.                    |
| A handoff    | Chooses the next node and carries its data. See below.              |
| A throw      | Treated as a failure.                                               |

***

## Route to the next node yourself

A node with one outbound transition takes it automatically (row 3 above). A node that branches has more than one, so the runtime cannot guess. Return a handoff and the script names the branch itself.

Call `require('asteroid').handoff(...)` and **return the result**.

```javascript theme={null}
const asteroid = require('asteroid');

module.exports = async ({ page }) => {
  await page.fill('#username', '##USERNAME##');
  await page.fill('#password', '##PASSWORD##');
  await page.click('button[type="submit"]');

  // Wait for whichever outcome the portal renders.
  await page.waitForSelector('#dashboard, #login-error');

  if (await page.locator('#login-error').isVisible()) {
    return asteroid.handoff({
      to: 'login_failed',
      summary: 'The portal rejected the credentials.',
    });
  }

  return asteroid.handoff({
    to: 'dashboard',
    summary: 'Logged in and reached the dashboard.',
  });
};
```

Here the node has two AI transitions, to a node named "Dashboard" and a node named "Login Failed". The script reads the page and picks one. No model runs.

### The target slug

`to` is the **slug of the target node's display name**. The slug is the name in lowercase, with every run of non-letter, non-digit characters turned into a single `_`, and any leading or trailing `_` removed.

| Target node name  | `to` value      |
| ----------------- | --------------- |
| `Dashboard`       | `dashboard`     |
| `Login Failed`    | `login_failed`  |
| `File Claim (v2)` | `file_claim_v2` |

A handoff to a name that is not a live target fails the script. The error lists the valid targets, so a wrong slug is easy to fix.

<Note>
  Handoff targets are the node's **AI transitions** only. You never hand off to a [selector transition](/concepts/transitions). A selector is matched automatically, at higher precedence than a handoff (row 1 above).
</Note>

### Carrying data on the handoff

Pass **either** `output` **or** `variables`, never both.

* Use `output` when the transition has a schema. The object is validated against that schema and arrives at the next node as `{{.output}}`, exactly as an AI transition's payload would.
* Use `variables` (an array of `{ name, value }`) when the transition has no schema. Each entry becomes a named output variable.

```javascript theme={null}
// Transition has an output schema:
return asteroid.handoff({
  to: 'file_claim',
  summary: 'Found the patient.',
  output: { patient_id: id, plan_name: plan },
});

// Transition has no schema:
return asteroid.handoff({
  to: 'file_claim',
  summary: 'Found the patient.',
  variables: [
    { name: 'patient_id', value: id },
    { name: 'plan_name', value: plan },
  ],
});
```

<Warning>
  Watch out for these mistakes:

  * **Building a handoff but not returning it.** Always `return asteroid.handoff(...)`.
  * **An `output` that does not match the transition's schema**, or a value that is not JSON. Pass plain data, never a Playwright locator or element handle.
</Warning>

***

## What happens when the script fails

The picker beside the Script field has two settings.

<CardGroup cols={2}>
  <Card title="Fall back to AI" icon="sparkles" horizontal>The failure context joins the model's turn. The workflow recovers and finishes the task from the instructions.</Card>
  <Card title="Cancel" icon="ban" horizontal>The execution cancels at once with the reason `script_failed`. No model call. This also fires when the script file is missing.</Card>
</CardGroup>

Choose **Fall back to AI** while a script is new and you're monitoring it closely, or where the stakes are low. Choose **Cancel** in situations where an AI making a mistake is not an acceptable outcome.

***

## Pass data into a script

Data that changes per execution reaches the script as `args`. Declare an input schema on the node, then read each input by name.

```javascript theme={null}
/**
 * @param {object} args
 * @param {string} args.patient_name - Patient to file the note against.
 * @param {string} args.date_of_birth - Date of birth, as shown in the EHR.
 */
module.exports = async ({ page, args }) => {
  await page.fill('#username', '##USERNAME##');
  await page.fill('#password', '##PASSWORD##');
  await page.click('button[type="submit"]');

  await page.fill('#patient-name', args.patient_name);
  await page.fill('#dob', args.date_of_birth);
  await page.click('#save');

  return { saved: true };
};
```

A scripted node that declares an input schema must use the `async ({ page, args }) => { ... }` signature. The plain `async (page) => { ... }` form fails validation.

Tell Astro which values change per execution and it declares the schema and wires the `args` for you.

<Tip>
  Write a JSDoc `@param` block for every argument. The workflow sees that JSDoc when a script fails and the node falls back. It is the only description of the arguments the workflow gets.
</Tip>

By default, each `args` value comes from an exact-name match against the workflow inputs and the outputs of upstream nodes. When nothing matches a name, the model composes that value. The next section pins a value to an exact source instead, so no model call is needed.

***

## Pin a script's inputs

Add `x-source` to a property in the node's input schema to say exactly where its value comes from. A pinned value is filled with no model turn, so a script that reads only pinned inputs runs model-free from end to end.

```json theme={null}
{
  "type": "object",
  "properties": {
    "patient_id": {
      "type": "string",
      "description": "The patient's MRN.",
      "x-source": { "from": "node_output", "node": "look_up_patient" }
    },
    "date_of_service": {
      "type": "string",
      "description": "Service date, as YYYY-MM-DD.",
      "x-source": { "from": "workflow_input" }
    }
  },
  "additionalProperties": false,
  "required": ["patient_id", "date_of_service"]
}
```

Three forms are allowed.

| `x-source`                                    | Where the value comes from                                        |
| --------------------------------------------- | ----------------------------------------------------------------- |
| `{ "from": "workflow_input" }`                | The workflow input of the **same name** as this property.         |
| `{ "from": "node_output", "node": "<node>" }` | The output variable of the **same name** from that upstream node. |
| `{ "from": "llm" }`                           | Force the model to compose it, even when a name would match.      |

A few rules around pins:

* **The property's own name is the source name.** To pin `patient_id`, there must be a workflow input, or an upstream output, called `patient_id`. You cannot rename across the pin.
* `node` may be a **list** of nodes. When several have emitted the same name, the most recent one wins.
* In a workflow's files, `node` is the referenced node's directory name. In the API and MCP graph form, it is that node's id. This is the same reference you already use to point a transition at a node.
* If a pin has **no value** at run time, that property falls back to the model. The pin never blocks the run.
* A schema-shaped handoff `output` is stored as one `output` variable. You can also pin to any of its top-level fields by their own name.

<Card title="Inputs and outputs" icon="arrow-right-left" href="/concepts/inputs-and-outputs" horizontal>How inputs reach a node and how outputs travel between nodes</Card>

***

## Credentials in a script

`##CREDENTIAL##` tokens are replaced at the tool boundary, straight from the credential store. They never pass through the model.

```javascript theme={null}
await page.fill('#username', '##USERNAME##');
await page.fill('#password', '##PASSWORD##');
```

The token name is the credential key in upper case, wrapped in `##`. Store the values on the [Agent profile](/concepts/profiles). See [Credentials](/concepts/credentials) for the full model.

***

## Patterns for coding agents

These recipes combine handoffs, pinned inputs, and the [workflow filesystem](/concepts/filesystem) into whole paths that need no model.

### The filesystem as loop memory

A node can loop back to itself to work through a list one item at a time. That keeps each pass's context small, which matters on a long list. Record finished items in `workspace/` so a loop-back never redoes one. If the session drops and the run re-logs in, the same run picks up where it left off.

`workspace/` is scratch for the current execution. It survives every step and loop-back inside that run, then is cleared when the run ends. So this is within-run memory only: a fresh execution starts the list over. (Do not use `shared/` for this: a running script's writes to `shared/` are not saved back. See the [Script runtime](/concepts/scripting-runtime#the-filesystem).)

```javascript theme={null}
const fs = require('node:fs');
const asteroid = require('asteroid');

const DONE_FILE = '/home/agent/workspace/filed.json';

/**
 * @param {object} args
 * @param {string} args.claim_ids - JSON array of claim ids to file this run.
 */
module.exports = async ({ page, args }) => {
  const todo = JSON.parse(args.claim_ids);
  const done = fs.existsSync(DONE_FILE)
    ? JSON.parse(fs.readFileSync(DONE_FILE, 'utf-8'))
    : [];

  const next = todo.find((id) => !done.includes(id));
  if (next === undefined) {
    return asteroid.handoff({ to: 'all_filed', summary: 'Every claim is filed.' });
  }

  // ... file claim `next` on the page ...

  done.push(next);
  fs.writeFileSync(DONE_FILE, JSON.stringify(done));
  // Hand back to this same node to take the next item.
  return asteroid.handoff({ to: 'file_claim', summary: `Filed ${next}. More to do.` });
};
```

### Verify against a file before you submit

Read a reference file the caller staged in `uploads/`. Check the page against it. Submit only on a match, and route to review on a mismatch. The guardrail is deterministic, so it costs nothing and never drifts.

```javascript theme={null}
const fs = require('node:fs');
const asteroid = require('asteroid');

module.exports = async ({ page }) => {
  const expected = JSON.parse(
    fs.readFileSync('/home/agent/uploads/expected.json', 'utf-8')
  );

  const shown = (await page.locator('#total-amount').innerText()).trim();
  if (shown !== String(expected.total)) {
    return asteroid.handoff({
      to: 'needs_review',
      summary: `Page shows ${shown}, file expects ${expected.total}.`,
    });
  }

  await page.click('#confirm');
  return asteroid.handoff({ to: 'submitted', summary: 'Amount matched; submitted.' });
};
```

### A document that arrives as a PDF or a print-only page

The sandbox has no PDF library, so you cannot parse a PDF with a Node module. Parse it **inside the browser page** instead. Keep a `pdf.js` build in `shared/`, inject it with `page.addScriptTag`, then read the text in the page context.

```javascript theme={null}
const fs = require('node:fs');

module.exports = async ({ page }) => {
  // No Node PDF module exists in the sandbox, so parse in the page.
  // A bundled pdf.js in shared/ needs no network fetch.
  const pdfjs = fs.readFileSync(
    '/home/agent/shared/read_document/pdf.min.js',
    'utf-8'
  );
  await page.addScriptTag({ content: pdfjs });

  const text = await page.evaluate(async () => {
    // A print-only page served as HTML has no PDF source: read its text directly.
    const pdf = document.querySelector('embed[type="application/pdf"], iframe[src$=".pdf"], embed[src$=".pdf"]');
    if (!pdf?.src) return document.body.innerText;

    const doc = await window.pdfjsLib.getDocument(pdf.src).promise;
    let out = '';
    for (let i = 1; i <= doc.numPages; i++) {
      const p = await doc.getPage(i);
      const content = await p.getTextContent();
      out += content.items.map((it) => it.str).join(' ') + '\n';
    }
    return out;
  });

  return { document_text: text };
};
```

<Card title="Script runtime" icon="wrench" href="/concepts/scripting-runtime" horizontal>The runtime contract: timeouts, the sandbox, the filesystem, `require`, and credentials</Card>

***

## Next

<CardGroup cols={2}>
  <Card title="Script runtime" icon="wrench" href="/concepts/scripting-runtime" horizontal>Timeouts, sandbox limits, and the runtime contract</Card>
  <Card title="Improve your workflows" icon="trending-up" href="/operate/improve" horizontal>The rest of the ways to make a workflow faster</Card>
  <Card title="Nodes" icon="box" href="/concepts/nodes" horizontal>Where a script lives, and the rest of a node's settings</Card>
  <Card title="Agent profiles" icon="id-card" href="/concepts/profiles" horizontal>Store the credentials a script fills in</Card>
</CardGroup>
