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(noconst,var, orfunctionkeywords) - 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_namedeclares 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.reduceinstead offororwhile
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.yamlreports 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:
- Generate a sample with
archagent describe configsamples script. - Write the custom logic in your local file.
- Validate with
archagent validate configs -k script -f ./path/to/script.yaml. - 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:
- Open Scripts in the portal.
- Run a script with sample input to verify behavior.
- 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 moreitcases 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 everyexpect(...)inside it passes and the body does not raise a runtime error. Anitwith no assertions is reported as a failure — callexpectat least once.test.beforeEach(fn())/test.afterEach(fn())— hooks that run around everyitin the enclosingdescribe(and nested describes). Outer hooks run before inner hooks.afterEachstill 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 aResult.ok.toBeError()— value is aResult.err.toContain(item)— list contains item, or string contains substring.toMatch(pattern)— string matches a regex.toHaveLength(n)— list / string / map hasnelements
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 askind: Script. - Test scripts:
*.test.aascript— deploy askind: 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.
Have feedback?
Help us make this page even more useful.
Tell us what you'd like to see expanded, which examples would help, or what workflow you want covered next. Every message gets read.