Scripts

Write focused custom logic for workflow steps, policy checks, adapters, and other places where built-in nodes are not enough.

Overview

Scripts are where you put the small pieces of custom logic that give a workflow or routine its project-specific behavior.

They are useful when the platform already gives you the overall structure, but you still need code for the part that is unique to your business.

Typical uses include:

  • reshaping data between steps
  • applying policy checks
  • adapting one system's format to another
  • making a routing decision that is too custom for a simple expression

A script is a small, reviewable piece of logic inside an otherwise declarative flow, not a place to write arbitrary code.


Language basics

The ArchAgents script language is expression-oriented. The last expression in the script body is the return value -- there is no return keyword.

Key syntax rules:

  • Variables: let x = 10 (no const, var, or function keywords)
  • Anonymous functions: fn(x) { x * 2 }
  • Imports: import("array"), import("requests"), etc.
  • Input payload: $ gives access to the input data via JSONPath
  • Input declarations: input var_name declares variables from the execution environment (for workflow step outputs)
  • Environment variables: env.API_KEY, env.SLACK_WEBHOOK, must be set at the organization level first (see Setting env vars for your scripts)
  • Comments: // single-line and /* */ multi-line
  • Semicolons: optional (automatic semicolon insertion)
  • No loops: use array.map, array.filter, array.reduce instead of for or while

Available import namespaces: requests, array, string, map, datetime, math, result, email, jwt, slack, plus app-specific namespaces like agents, threads, users. See the Script Language Reference for the full list of namespaces, functions, and signatures.


Gradual typing

Scripts support optional type annotations. You can write loose, untyped code while you're prototyping, and add types where you want stronger guarantees, at function boundaries, on workflow inputs, or on object shapes you pass between steps.

// Annotate a function with parameter and return types
let parse_amount = fn(value: string): number {
 string.toNumber(value) || 0
}

// Annotate the input payload shape
input $: {
 order_id: string,
 items: array<{ sku: string, quantity: number }>
}

// Annotate a local with an object shape
let summary: { total: number, count: number } = {
 total: 0,
 count: array.length($.items)
}

When you annotate a value, the script engine uses bidirectional inference: it propagates the type into expressions that depend on it, narrows types inside conditionals, and reports a structured error when an operation doesn't fit.

Type-checking happens at validation time:

  • archagent validate configs -k script -f ./script.yaml reports type mismatches alongside syntax errors
  • the portal script editor surfaces type diagnostics as you type
  • typed function boundaries also enforce types at runtime, so a typed parameter that receives the wrong shape fails fast instead of silently producing wrong output

You can also use interfaces to define a structural shape once and reuse it:

interface Order {
 id: string
 items: array<{ sku: string, quantity: number }>
 total: number
}

let total_for = fn(order: Order): number {
 order.total
}

Typing is gradual on purpose: untyped code keeps working, and you can add annotations to the parts that matter without rewriting everything else. The type system is documented in full in the Script Language Reference.


A concrete example

Imagine a workflow that processes refund requests. Most of the workflow stays visual -- receive the request, gather account info, check approval, send the result. The script handles the custom part in the middle: calculate the refund, normalize billing data, enforce a business rule.

let http = import("requests")
let arr = import("array")

let items = $.order.line_items
let eligible = arr.filter(items, fn(item) { item.refundable == true })

let totals = arr.map(eligible, fn(item) {
 {
 sku: item.sku,
 refund_amount: item.price * item.quantity
 }
})

let grand_total = arr.reduce(totals, 0, fn(acc, t) { acc + t.refund_amount })

let approval = unwrap(http.post(env.BILLING_API_URL, {
 headers: { "Authorization": "Bearer " + env.BILLING_API_KEY },
 body: { order_id: $.order.id, amount: grand_total }
}))

{
 eligible_items: totals,
 total_refund: grand_total,
 approval_id: approval.body.id
}

That script reads the input payload with $, filters and transforms data with array functions, calls an external API with requests, and returns a structured object for the next workflow step.


Execution contexts

Where a script runs determines what $ contains and what capabilities are available.

Context $ contains env available Builtin tools available
Workflow ScriptNode Step input data Yes No
Routine handler (script type, single-handler) Event payload Yes No
Chain routine step (script or workflow) {trigger: event, inputs: {step_name: output, …}} Yes No
Custom tool script Tool arguments Yes No
do_task preset N/A (LLM has full tool access) Yes Yes (all agent tools)

Scripts run under the same scoped platform authorization model as the routine or workflow that invoked them.

Scripts can also use input var_name to declare named variables from the execution environment. This is useful when a workflow step outputs a named result that the next script needs to consume. Unknown identifiers are errors; declare them with let or input.

Chain-step input shape

When a script runs inside a step of a chain routine (handler_type: chain), it does not see the raw event at $. The platform wraps the input so each step can read both the original trigger and any upstream step's output by name:

{
 "trigger": <original event payload>,
 "inputs": {
 "<upstream_step_name>": <upstream_step_output>,...
 }
}

So a chain-step script addresses the trigger event via $.trigger.<field> and upstream outputs via $.inputs.<step_name>. Scripts that run inside a workflow_graph step see the same wrapped shape: the workflow's internal ScriptNodes address $.trigger and $.inputs.<step_name> the same way. Only chain-step scripts are wrapped; single-handler script routines continue to see the raw event payload at $.

Unnamed steps' outputs are still written to the run's metadata but aren't addressable by name from downstream steps. Give every step you want to reference a unique name.

Custom-tool scripts: the calling thread

When a script is the handler for a custom tool, the tool's argument map lives at $ as usual. The script can also read the thread the tool was invoked from with threads.current():

let threads = import("threads")
let thread = unwrap(threads.current())
let channelId = thread.metadata.slack_source.channel_id

This is the right path when the value the tool needs lives in thread metadata rather than in the tool's arguments. For example: the Slack channel that owns the conversation, the GitHub repo the thread is scoped to, or any other context the LLM shouldn't have to copy by hand. Use threads.current() instead of asking the LLM to fill those values into the tool's argument schema.


Scripts vs expressions

Feature Script Expression
Multi-step logic Yes No
Return value Last expression (implicit) Implicit evaluation
Imports Yes (import("namespace")) No
HTTP calls Yes (via requests) No
Error handling unwrap() builtin, result namespace Minimal
Use in workflows Full ScriptNode Inline conditions and field access
Best for Custom behavior, transformations Small checks, field access, routing guards

Use expressions when the logic is tiny and obvious -- a field comparison, a null check, simple string interpolation.

Use scripts when:

  • the code needs several steps or intermediate variables
  • you need to call an external service
  • the logic needs to be tested on its own
  • the transformation is central enough that it deserves a named, reusable unit

Common patterns

HTTP call with error handling

let http = import("requests")

let response = http.get(env.STATUS_API_URL, {
 headers: { "Authorization": "Bearer " + env.API_TOKEN }
})

let body = unwrap(response, { status: "unknown" })
{ service_status: body.status }

Conditional notification

let mail = import("email")

let amount = $.invoice.total
let recipient = if (amount > 10000) { env.ALERTS_EMAIL } else { env.INFO_EMAIL }

unwrap(mail.send({
 to: recipient,
 subject: "Invoice " + $.invoice.id,
 text_body: "Amount: $" + string.toString(amount)
}))

{ notified: true, to: recipient }

Data pipeline

let arr = import("array")
let str = import("string")

let raw = $.records

let cleaned = arr.filter(raw, fn(r) { r.email != null })

let normalized = arr.map(cleaned, fn(r) {
 {
 email: str.lowercase(r.email),
 name: str.trim(r.name),
 source: "import"
 }
})

let by_domain = arr.reduce(normalized, {}, fn(acc, r) {
 let domain = str.split(r.email, "@").1
 let existing = map.get(acc, domain, [])
 map.put(acc, domain, arr.concat(existing, [r]))
})

{ processed: arr.length(normalized), by_domain: by_domain }

Setting env vars for your scripts

Scripts reference environment values with env.VAR_NAME, for API tokens, webhook URLs, or any configuration that shouldn't live in the script source. Those values are set at the organization level so every script, routine, and workflow in the org can read them.

Create them with the CLI (every command requires --org <org_id>):

archagent create orgenvvar --org <org_id> --key MY_API_KEY --value "sk-..."
archagent create orgenvvar --org <org_id> --key ALERTS_SLACK_WEBHOOK --value "https://hooks.slack.com/..."

List and manage existing ones:

archagent list orgenvvars --org <org_id>
archagent describe orgenvvar <id> --org <org_id>
archagent update orgenvvar <id> --org <org_id> --value "new-value"
archagent delete orgenvvar <id> --org <org_id>

Once set, any script can read the value at runtime:

let http = import("requests")
let resp = http.get("https://api.example.com/orders", {
 headers: { "Authorization": "Bearer " + env.MY_API_KEY }
})

A script that references env.MY_API_KEY passes validation whether or not the variable is set, the type checker treats env.* as a string. At runtime, an unconfigured variable returns an empty string. Set the env vars before deploying scripts that reference them so every call resolves to the expected value.


Validation

The CLI validates script syntax with archagent validate configs, and the portal also validates syntax when you save. Syntax errors (mismatched braces, unknown operators, malformed expressions) are caught at validation time.

However, validation does not check runtime function availability. A script that calls a function that does not exist in the imported namespace will pass validation but fail at execution time. Always test scripts with sample input before deploying them in a live workflow.


Writing and testing scripts

Write scripts locally in your editor or coding agent, then deploy them as configs:

  1. Generate a sample with archagent describe configsamples script.
  2. Write the custom logic in your local file.
  3. Validate with archagent validate configs -k script -f ./path/to/script.yaml.
  4. Deploy with archagent deploy configs.

Files ending in .agentscript or .aascript are also recognized by the deploy step, useful when you want to keep the script body in its own file rather than embedded inline in YAML.

You can also validate and run scripts directly from the CLI:

archagent validate scripts --file./path/to/script.yaml
archagent run scripts --file./path/to/script.yaml --input '{"key": "value"}'
archagent describe scriptdocs

archagent describe scriptdocs prints the full script language reference.

The portal also provides a script editor with a built-in test runner:

  1. Open Scripts in the portal.
  2. Run a script with sample input to verify behavior.
  3. Use version history for rollback if a later change is wrong.

Good scripts are small enough to review quickly, narrow enough to explain in one sentence, easy to test with sample input, and focused on one job. When a script starts absorbing too much workflow logic, the visual process disappears and the workflow becomes a box of code -- a sign that the script should be split or the workflow restructured.


Writing tests for a script

A test is itself a script — import("test") gives you a Jest-style API for grouping cases, asserting on results, and mocking namespace calls. Test files live next to your production scripts and end in .test.aascript.

// add.test.aascript

let test = import("test")
let math = import("script:math-helpers")   // the script you're testing

test.describe("add", fn() {
  test.it("adds two positive numbers", fn() {
    test.expect(math.add(2, 3)).toEqual(5)
  })

  test.it("treats null as zero", fn() {
    test.expect(math.add(null, 4)).toEqual(4)
  })
})

Available API

The test namespace exposes:

  • test.describe(name, fn()) — groups one or more it cases under a named suite. Nestable; the test report shows the full path (outer > inner).
  • test.it(name, fn()) — declares a single test case. A test passes when every expect(...) inside it passes and the body does not raise a runtime error. An it with no assertions is reported as a failure — call expect at least once.
  • test.beforeEach(fn()) / test.afterEach(fn()) — hooks that run around every it in the enclosing describe (and nested describes). Outer hooks run before inner hooks. afterEach still runs when the test body errors, so it's safe to use for teardown.
  • test.expect(actual).<matcher>(...) — record an assertion. Matchers:
    • .toEqual(expected) — structural equality (==)
    • .toBe(expected) — strict identity (===)
    • .toBeOk() — value is a Result.ok
    • .toBeError() — value is a Result.err
    • .toContain(item) — list contains item, or string contains substring
    • .toMatch(pattern) — string matches a regex
    • .toHaveLength(n) — list / string / map has n elements

Matchers never raise — a failed match records a failing assertion entry and the rest of the it keeps running so you see every failure, not just the first.

Mocking external calls

Use test.mock inside an it to swap out a namespace method for that one test. The mock is auto-removed when the it returns, so the next case sees the real implementation again. Use test.spy to record what a method was called with (combine with test.mock first if you also want to replace the implementation).

test.describe("notify", fn() {
  test.it("posts a single slack message per alert", fn() {
    test.mock("slack.send", fn(args) { result.ok({ok: true}) })
    let s = test.spy("slack.send")

    let mod = import("script:notify")
    mod.run({alerts: [{level: "high"}]})

    test.expect(s.calls()).toHaveLength(1)
  })
})

Side-effecting namespaces (requests, slack, email, storage, etc.) are not auto-mocked — if you want a test isolated from the real network or DB, mock the methods it calls explicitly.

Rebinding input

test.withInput({...}, fn()) runs a body with $ rebound. Handy when one it block needs to exercise a script under several input shapes without splitting into separate tests:

test.it("handles both string and number ids", fn() {
  let mod = import("script:resolve-id")

  test.withInput({id: "abc"}, fn() {
    test.expect(mod.default).toEqual("abc")
  })

  test.withInput({id: 42}, fn() {
    test.expect(mod.default).toEqual(42)
  })
})

File layout

  • Production scripts: *.aascript (or *.agentscript) — deploy as kind: Script.
  • Test scripts: *.test.aascript — deploy as kind: ScriptTest.

Both file types are recognized by the deploy step; the compound .test.aascript extension routes to the ScriptTest config kind so tests are listed and run separately from production scripts. Test scripts run with Scripts.run_tests/2, which returns a structured {passed, suites, tests, assertion_count} report — that's what the CLI test runner and portal "Run tests" button consume.


Debugging scripts

When a script fails, check these in order:

1. Check the routine or automation run

archagent list agentroutineruns --routine <routine_id>

The run list shows status and error messages for each execution.

2. Use println for inspection

println outputs values to the console panel in the portal script editor. Use it to inspect intermediate values:

let data = $.payload
println("received:", data)
let items = data.items || []
println("item count:", array.length(items))

3. Common errors and fixes

Error Cause Fix
unknown_function: env Calling env() as a function Use env.KEY (dot access, not function call)
unknown_function: http_post Using wrong function name Use import("requests") then http.post(...)
unknown_identifier: params Expecting implicit variables Use $ for input payload, env.KEY for env vars
cannot_access_property on array Using .length property Use array.length(items) (function, not property)
invalid_arguments: array.map Input is not an array (e.g. got a 404 JSON response) Check the HTTP response before mapping: if (resp.body.items) {... }

4. Validation vs runtime

archagent validate configs checks syntax only. A script can pass validation but fail at runtime if:

  • an env var is not configured
  • an HTTP endpoint returns an unexpected response
  • a namespace function receives wrong argument types

Test scripts with sample input, either locally or in the portal editor, before deploying them in routines.


Further reading

See the Script Language Reference for the full specification, including all namespace functions, operator precedence, and error handling details.

When a script needs to remember small structured state between runs, a dedupe marker, a counter, a "last seen" value, reach for Key-Value Storage and the storage namespace. It is the deterministic, no-LLM-required complement to the agent's memory.