Code interpreter
When a workflow is present, the runtime wires up a QuickJS sandbox. archmax_eval,
archmax_run and lifecycle hooks all run in the same sandbox. What differs is
who chose to run the code, and on whose authority.
Cases are declarative YAML, interpreted host-side. The sandbox reaches a case through the hooks and scripts of the agent it drives.
Two entry points, one sandbox
Section titled “Two entry points, one sandbox”| Tool | Source of the code | Governance |
|---|---|---|
archmax_eval |
inline, written by the model | always-on; every state has it |
archmax_run |
an authored file in a skill bundle | always-on; narrow the paths per state (tools.allow) |
Both evaluate in the same REPL session for a run. A helper a script file defines
is therefore in scope for a later archmax_eval call. Both get:
- Programmatic tool calling (PTC), under the
toolsnamespace with camelCased names:await tools.readFile({ file_path: "skills/order-data/assets/orders.json" }). A tool’s own name reaches the same function (tools.read_file,tools["archmax_workflow_enrich-order"]), which is how a model tends to write it; the listing names only the camelCased ones. A hook’stoolstakes the camelCased names alone. It lists the active state’s tools: the ones the model’s own tool list carries in that state, without the runtime’s controls and the two sandbox tools. The listing is re-scoped at the start of every evaluation, so after anarchmax_advancethe nextObject.keys(tools)shows the new state’s tools.toolsstays one object for the whole REPL session, so a helper or aconst t = toolsfrom an earlier call reads the current state too, and a script that overwritestoolsgets it back at its next evaluation. A tool the state does not offer is left out of the listing, but its name still resolves: calling it is refused by governance with the same reason the model’s own call would get, which the script can catch. A tool’s result arrives as text:tools.readFileresolves to the file’s content, or, for a binary file, to the noticeError: '<path>' is a binary file (…) and was not read; …(an image included, even with theimagesoption on, which shows images to the model only; see the workspace), so check before youJSON.parseit. To duplicate, move or delete a file, calltools.copyFile({ source, destination }),tools.moveFile(…)ortools.removeFile({ file_path })rather than reading and writing it back: the bytes move, binary files included, without paging throughreadFile’s line limit, and each resolves to one line (Copied …,Moved …,Removed …, orError: …). console.*capture and REPL state that persists across calls.- The last expression is returned to the model.
There is no task() global in any sandbox context, hook scripts included.
Dispatching a grading rubric is the runtime’s own job, through the framework
task tool that a safety rule keeps out of the agent’s reach.
What an archmax_run script receives
Section titled “What an archmax_run script receives”The script reads its input as the global args: the arguments the model passed,
plus args.variables. args.variables holds the session’s variables as a flat
name → value map, exactly as a hook receives them. A script therefore reads the
run’s input without the model having to retype it:
/** One sub-run per order in `orders_to_enrich`. */const orders = args.variables?.orders_to_enrich ?? [];const results = await Promise.all( orders.map((o) => tools.archmaxWorkflowEnrichOrder({ order_id: o.order_id })),);results;args.variables is a read-only snapshot. The variable tools are excluded from
PTC, so a script cannot write the store. It returns a value instead, and the
model records it with archmax_set_variables. A state’s requires: is what makes
that mandatory.
${{…}} is not substituted inside a script’s tools.* calls. The script already
holds the values and composes strings in JavaScript, so such text in an argument
is passed to the tool verbatim. The call is still governed exactly as the model’s
own would be.
A state’s ${{name}} argument guard resolves against the run’s variables. So a
script passing the literal value the guard names is admitted, and any other value
is blocked.
archmax_run reads its source through the configured backend. Authored
scripts therefore run against whatever backend the runtime is configured with, a
remote one included.
Configure the sandbox via settings in workflow.yaml:
settings: timeoutMs: 15000A script lives as long as its turn
Section titled “A script lives as long as its turn”When a host cancels a turn by aborting the signal it passed to send, every
script of that turn stops. That covers archmax_eval, archmax_run and hook
scripts, in the session and in the child sessions it dispatched:
- The evaluation ends with an error at once, not at
timeoutMs. - A
tools.*call still pending fails withTool '<name>' failed: the turn was aborted. - A call the script makes after that is refused the same way, before it reaches
the tool. A retry in a
catchtherefore starts nothing, and no sub-workflow is dispatched under a cancelled turn.
Governance
Section titled “Governance”archmax_eval is always on: every state has it without declaring it. That is
safe because evaluated code reaches exactly what the state already permits (see
the PTC rule below). The sandbox adds computation over a state’s surface.
archmax_run is always on for the same reason. A script is a file the state could
already read, and running it reaches what an inline archmax_eval of the same
source reaches.
Which authored files a state may execute is still a real governance question.
Answer it by narrowing, the way you would narrow write_file. A state’s own
entry wins over the always-on grant:
states: orders-question: tools: allow: # only these scripts may run in this state - { tool: archmax_run, args: { file_path: ["skills/orders/scripts/lookup-*.js"] } }archmax_run executes only scripts inside a skill bundle. A non-overridable
kernel rule (script.skill-only) enforces that ahead of workflow policy,
consumer rules, and the per-state allow list. A state’s entry therefore
narrows within the bundles. Two consequences:
- A script the agent writes into
scratchpad/cannot be run. - A lifecycle hook cannot be run by the agent either. Hooks live on the
authoring plane (
workflows/<slug>/hooks/). The runtime runs them, and the agent can neither read nor execute them. See The authoring plane.
An entry naming paths outside every bundle (["**"], ["scratchpad/**"]) grants
nothing, and archmax validate reports it as inert. A state that declares no entry
may run any bundled script its enabled skills expose.
Every PTC call sandboxed code makes is also governed, individually, at the moment it runs. That code runs on the model’s authority.
Take tools.writeFile(...) from inside skills/orders/scripts/lookup.js, or from
an inline archmax_eval snippet. It is checked against the active state exactly as
a write_file tool call from the model would be: same allow list, same argument
constraints, same tools.forbid_always, same safety rules. So governing
archmax_run by path decides which files may run, and what a file may do once it
runs stays with the state.
To close the sandbox for a whole workflow, deny both tools in
tools.forbid_always.
Workflow denials are evaluated ahead of the per-state defaults, so a denial there
outranks the always-on grant.
An allow_always entry constraining archmax_eval is inert. Always-on tools
are permitted everywhere, and a per-state entry is what narrows them.
archmax validate warns about it.
A tool named bare eval (upstream’s default name for an interpreter, or a host
tool claiming it) stays blocked by a non-overridable safety rule.
A refused call throws inside the sandbox, so a script can handle it:
try { await tools.writeFile({ file_path: "scratchpad/report.md", content: body });} catch (err) { // err.message carries the governance reason, e.g. "[workflow] BLOCKED: …"}The block is fail-closed whether or not the script catches it: the underlying tool never runs.
Blocked and allowed PTC calls both surface on the event stream (tool-blocked,
tool-called/tool-result). Each carries origin: "script", or "lifecycle"
for a hook. That is how the CLI renders them as
read_file skills/order-data/assets/orders.json via lifecycle hook.
Lifecycle hooks
Section titled “Lifecycle hooks”archmax_run lets the model choose to run a script. before/after hooks
belong to the runtime, which runs them deterministically at fixed points in
the graph. They use the same sandbox and the same PTC bridge.
A hook runs on the runtime’s authority, so the active state’s allow list does
not narrow its tools.* calls, and its tools lists every tool on the
bridge rather than the state’s. A hook usually needs evidence the state itself
withholds. Everything else still binds:
- the non-overridable safety rules, a write into the read-only authored zone among them
- the workflow’s
tools.forbid_always - any consumer governance rules
A hook is a JavaScript file whose default export is an async function. It is called with one object and answers with a verdict:
/** Vetoes a request from a requester the order records do not know. */export default async function hook({ state, phase, trigger, variables, messages, tools }) { const orders = JSON.parse(await tools.readFile({ file_path: "skills/order-data/assets/orders.json" })); const request = messages.find((m) => m.role === "user")?.text ?? ""; const email = request.match(/[\w.+-]+@[\w.-]+/)?.[0]; if (!email) return veto("No email address found in your request."); if (!orders.some((o) => o.email === email)) return veto(`"${email}" is not in the order database.`); return ok();}The input (HookInput in @archmax-ai/harness/sandbox; also available as the global args):
| Field | Meaning |
|---|---|
state, phase |
The state whose hook is running, and "before" or "after". |
trigger |
The trigger id the run started from. The run’s input arrives in variables. |
variables |
The session’s variables as a plain name → value map, including the built-in trigger. Read-only. |
messages |
The recent transcript, newest last, as { role, text, toolCalls? } objects. role is user, assistant, tool (with tool: <name>), system, or runtime. A runtime entry is a note the runtime wrote (an arrival, a decision, an error route), and note says which kind. |
from, to, reason |
after hooks on an advance: the transition being attempted and the agent’s reason. |
tools |
The PTC bridge, camelCased: tools.readFile({ file_path }), which resolves to the file’s text, or to the binary notice for a binary file. A hook’s lists every tool on the bridge, not only the state’s. |
The verdict helpers are globals:
| Verdict | Effect |
|---|---|
ok() |
Proceed. Returning nothing means the same. |
veto(reason) |
Block, with the reason shown to the agent. |
correct(reason) |
after hooks only: hand the state back for another attempt, bounded by the hook’s max_iterations. |
Any other value is no verdict, and vetoes fail-closed. Hooks are fail-closed
throughout: one that throws, or whose file is missing, vetoes with the error as
the reason. If a script can fail for reasons that should not block the
transition, catch the error and return ok().
A script that wants a model verdict gets a rubric: hook declared beside it in
the same list, and the runtime dispatches that one.
defineHook(fn) is an alias for export default fn. A file with no default
export is evaluated as a plain script, and its completion value is the verdict.
So orders.length ? ok() : veto("no orders") on the last line works too.
A bare false is read as a veto, with the reason precondition not met. The
transcript is messages; there is no other view of the session.
Typed sandbox imports
Section titled “Typed sandbox imports”Hook files may import their authoring types from the SDK:
import { ok, veto, correct, defineHook } from "@archmax-ai/harness/sandbox";The import is a pure type carrier for IDE completion. The line is stripped before QuickJS evaluates the file, and inside the sandbox the names come from the prelude.
The sandbox accepts @archmax-ai/harness/* specifiers. Any other import is an
error, reported by archmax validate and refused at execution. Authoring
against the bare globals, with no import line at all, stays valid.
Description frontmatter
Section titled “Description frontmatter”Every authored code file (hooks and archmax_run scripts) opens with a plain
leading JSDoc block (/** ... */): a title line, a blank line, then a
description in prose. It is the code-file analogue of workflow.yaml’s
description, and admin UIs display it instead of the code. It has no bearing on
runtime behavior.
Write the description deep enough to review on its own. Cover what the code does
and how it does it: the inputs it reads, the sequence of checks, and each
outcome it can produce. For a hook, that means the condition behind every ok,
correct and veto. Go past a one-line label.
Write prose a human can refine like a specification, then make the code follow. Update the block before you change the code, so the two stay in step.
There are no special tags. The extractor truncates the description at the
first @tag line, so keep the title and body above any tag.
archmax validate does not check the block, so it stands as an authoring
convention. (Cases carry a mandatory description key, which their schema does
enforce.) Consumers extract the block with the exported
parseCodeDescription
helper.
The working area
Section titled “The working area”Each session gets one isolated, agent-visible working area at scratchpad/….
It sits alongside the runtime’s offload areas and the runtime-internal checkpoints
and artifacts.
On disk it is a local directory per session under
<root>/sessions/<sessionId>/scratchpad/, gitignored, and addressed without the
session id. Everything a run produces goes here: staged CSVs, unzipped payloads,
files handed between scripts, and the artifacts a state is asked to write.
scratchpad/** reads and writes are permitted in every state, with no
per-state allow entry. An entry naming a path inside the area does not narrow
where within it a write may land, and archmax validate warns when you author
one.
When the write itself must be governed, name an ordinary run path. Any run-root
path outside the reserved areas is matched against the state’s tools.allow.
The working area persists for as long as the rest of the run, with no separate retention setting.