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.
Module Structure
Section titled “Module Structure”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.jsonDefining a Module
Section titled “Defining a Module”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,}) {}Defining a Command
Section titled “Defining a Command”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, ]), ]; },});Defining a Helper
Section titled “Defining a Helper”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)); },});Argument Types
Section titled “Argument Types”| Type | Description |
|---|---|
address | Ethereum address |
number | Integer (possibly large) |
string | String value |
bytes | Hex-encoded bytes |
bytes32 | 32-byte hex value |
bool | Boolean |
array | Array of values |
any | Any type |
write-abi | Function signature (state-changing) |
read-abi | Function signature (view/pure) |
token-symbol | Token symbol or address |
block | Block of sub-commands |
variable | Variable name ($name) |
helper | Helper reference (@name) |
command | Command reference |
expression | Expression to be evaluated |
dao | DAO identifier |
json-path | JSON path expression |
Argument Options
Section titled “Argument Options”{ name: "params", type: "any", optional: true, // Argument is not required rest: true, // Collects remaining arguments as an array}Marking Things Experimental
Section titled “Marking Things Experimental”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.
Running Codegen
Section titled “Running Codegen”After adding commands or helpers, regenerate the import map:
cd modules/my-modulebun ../../packages/sdk/scripts/codegen.tsThis creates src/_generated.ts with lazy imports and metadata.
Accessing Module State
Section titled “Accessing Module State”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 // ...}Status boxes
Section titled “Status boxes”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;}outcomeis how the actions ended, through whatever carried them: sent directly, insidebatch/safe:execute/aragonos:forward, or proposed withsafe: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-sentmeans the actions never went out (for example, the run stopped first);unknownmeans 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. WithfollowsandshowWhenConfirmed: trueit shows only once those actions are confirmed, right after the carrier's box, and never if the carrier fails (its box says why).hidden: truekeeps any box out of sight until you callreveal(); a hidden box that ends is never shown. - End a box with
done,failorcancel(⊘, for something that was cancelled rather than failed). box.update({ countdown: { label, due, from, until } })(Unix seconds) shows a ticking countdown in the terminal:labeland the time left ("Segment 2 opens in 4m 12s"), thendueonceuntilhas passed.countdown: nullclears it; it is never logged as a line, and it clears when the box ends.box.update({ steps: [{ state, href, current }, …] })(withprogress) draws one bar segment per step: thecurrentstep grows from the countdown'sfromtountil, solid oncedoneand faint whileopen; earlierdonesteps are full (a link when they have anhref),missedones hatched,pendingones empty. Withoutsteps, the firstprogresssteps are full and the countdown's optionalsegmentgrows.- A box with a
watchkeeps a real run open until it ends; simulations never wait, and a box opened insidesim:forkends when the fork does. Cancel abortsbox.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.
Building and Testing
Section titled “Building and Testing”bun run build # Build all packagesbun test:unit # Run testsSmart-batch command compilation
Section titled “Smart-batch command compilation”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.