Skip to content

Writing a Module

EVMcrispr is extensible through modules. This guide covers how to build a TypeScript module with the SDK — the kind that ships with EVMcrispr itself and appears in the Reference section. To publish reusable commands written in EVML itself, see Publishing Modules instead.

A module lives in modules/<name>/ and contains:

modules/my-module/
src/
commands/
my-command.ts
helpers/
my-helper.ts
_generated.ts # Auto-generated by codegen
index.ts # Module definition
package.json

Create src/index.ts:

import { defineModule } from "@evmcrispr/sdk";
import { commands, helpers } from "./_generated";
export default class MyModule extends defineModule({
name: "my-module",
commands,
helpers,
}) {}

Commands produce transactions (Action[]). Create src/commands/my-command.ts:

import { defineCommand, encodeAction } from "@evmcrispr/sdk";
import type MyModule from "..";
export default defineCommand<MyModule>({
name: "my-command",
description: "Do something on-chain.",
args: [
{ name: "target", type: "address" },
{ name: "amount", type: "number" },
],
opts: [
{ name: "from", type: "address" },
],
async run(module, { target, amount }, { opts }) {
return [
encodeAction(target, "transfer(address,uint256)", [
opts.from ?? target,
amount,
]),
];
},
});

Helpers produce values. Create src/helpers/my-helper.ts:

import { defineHelper } from "@evmcrispr/sdk";
import type MyModule from "..";
export default defineHelper<MyModule>({
name: "my-helper",
description: "Compute something.",
returnType: "number",
args: [
{ name: "value", type: "number" },
{ name: "multiplier", type: "number", optional: true },
],
async run(module, { value, multiplier = 2 }) {
return String(BigInt(value) * BigInt(multiplier));
},
});
TypeDescription
addressEthereum address
numberInteger (possibly large)
stringString value
bytesHex-encoded bytes
bytes3232-byte hex value
boolBoolean
arrayArray of values
anyAny type
write-abiFunction signature (state-changing)
read-abiFunction signature (view/pure)
token-symbolToken symbol or address
blockBlock of sub-commands
variableVariable name ($name)
helperHelper reference (@name)
commandCommand reference
expressionExpression to be evaluated
daoDAO identifier
json-pathJSON path expression
{
name: "params",
type: "any",
optional: true, // Argument is not required
rest: true, // Collects remaining arguments as an array
}

Set "experimental": true in the module's package.json to gate the whole module, or experimental: true in a defineCommand/defineHelper config (or on a single option definition) to gate one entry. Experimental items are excluded from execution, completions, and docs unless the build sets VITE_PUBLIC_EXPERIMENTAL=true. In hand-written docs, wrap prose about an experimental feature in an :::experimental block — it is stripped from builds that don't enable the flag.

After adding commands or helpers, regenerate the import map:

Terminal window
cd modules/my-module
bun ../../packages/sdk/scripts/codegen.ts

This creates src/_generated.ts with lazy imports and metadata.

Commands and helpers receive the module instance as their first argument. Use it to access shared state:

async run(module, args) {
const client = await module.getClient(); // Viem PublicClient
const chainId = await module.getChainId(); // Current chain ID
// ...
}

A command that starts something longer than its transaction can show its progress in a status box. Open it from interpreters.box, follow the actions the command returns, and update it from watch:

async run(module, args, { interpreters }) {
const actions = [/* … */];
const box = interpreters.box?.({ title: "My order 0x12…", detail: "Waiting for execution", follows: actions });
box?.watch(async ({ outcome, simulated }) => {
if (outcome.kind === "not-sent") return box.done("Prepared, not sent");
if (outcome.kind === "unknown") return box.done(outcome.reason);
if (outcome.kind !== "confirmed") return box.fail(`Not registered: ${outcome.reason}`);
if (simulated) return box.done("Registered (simulated)");
await box.poll(async () => {
const done = await checkProgress();
box.update({ detail: `${done}/4 done`, progress: [done, 4] });
return done === 4 ? "stop" : "continue";
}, { every: 60_000 });
box.done("Finished");
});
return actions;
}
  • outcome is how the actions ended, through whatever carried them: sent directly, inside batch / safe:execute / aragonos:forward, or proposed with safe:propose. The link to the carrier only decides which box ends after which; the console shows every box on its own, in the order they opened. not-sent means the actions never went out (for example, the run stopped first); unknown means they were sent or queued (a Safe App batch, a host that returned no receipt) but how they ended is not known.
  • A box shows as soon as it opens, even while whatever carries its actions (a transaction, safe:execute, a Safe proposal, a simulation) is still live. With follows and showWhenConfirmed: true it shows only once those actions are confirmed, right after the carrier's box, and never if the carrier fails (its box says why). hidden: true keeps any box out of sight until you call reveal(); a hidden box that ends is never shown.
  • End a box with done, fail or cancel (⊘, for something that was cancelled rather than failed).
  • box.update({ countdown: { label, due, from, until } }) (Unix seconds) shows a ticking countdown in the terminal: label and the time left ("Segment 2 opens in 4m 12s"), then due once until has passed. countdown: null clears it; it is never logged as a line, and it clears when the box ends.
  • box.update({ steps: [{ state, href, current }, …] }) (with progress) draws one bar segment per step: the current step grows from the countdown's from to until, solid once done and faint while open; earlier done steps are full (a link when they have an href), missed ones hatched, pending ones empty. Without steps, the first progress steps are full and the countdown's optional segment grows.
  • A box with a watch keeps a real run open until it ends; simulations never wait, and a box opened inside sim:fork ends when the fork does. Cancel aborts box.signal.
  • A wrapper that sends nothing itself reports what happened with interpreters.carry(innerActions, outcomePromise, box).
  • Every change to a box's detail is also a log line (title: detail), so the CLI and tests see it too; progress and link updates are not logged.
Terminal window
bun run build # Build all packages
bun test:unit # Run tests

Commands retain their ordinary names inside batch !(...), safe:propose <safe> !(...) and safe:execute <safe> !(...). Classify each command with smartSupport: runtime, static, or incompatible. Static and incompatible commands need a concrete reason. Add the classification to scripts/smart-command-inventory.json; scripts/check-smart-command-coverage.ts checks that every command is covered.

Mark individual argument and option definitions with runtime: true where execution-time values are supported. Unmarked fields remain build-time values. The metadata drives validation, editor diagnostics and generated documentation. Use snapshot: true on a runtime argument when several calls must consume the same resolved value, such as an amount shared by approvals and a protocol call.

Prefer one builder for ordinary execution and smart compilation. encodeAction and the encoding functions in @evmcrispr/sdk/onchain preserve typed values inside an active smart context. Never coerce a runtime value into a JavaScript number, boolean or address string. Use the executing account from module.getSender() for sender-dependent defaults.

For commands that need the raw AST, supply compile(ctx, node) alongside run. This hook returns a SmartCommandPlan with ordered actions and an optional primaryCall index. primaryCall identifies the protocol call whose declared ABI outputs can be captured with -> [$result]; approvals must not be selected implicitly. The hook compiles only inside a smart block. AST nodes, module instances and SDK clients must not be included in the final execution action.

Reuse compileOperand, smartValueParam, the runtime ABI builders and buildApprovalActions from @evmcrispr/sdk/onchain. Assertions and smart batches share ERC-8211 positional constraints: constraint i checks resolved word i. Use constrainWord to combine checks on the same word; appending another constraint would check the next word. Add parity and runtime-field tests for new transaction commands and adapter routes, including rejected build-time-only inputs. See the smart-batch guide for execution requirements and return capture limits.

For a command that can compile its own smart payload, mark the relevant block argument supportsSmartBlock: true and inspect block.smart to select its compiler. This is separate from smartSupport, which describes whether the command can run inside another smart payload. The framework rejects !(...) on other arguments before evaluating them.