WASI slash handlers

A workspace can install a sandboxed WebAssembly module as a slash-command handler. Someone types /report last-week in a thread, Maidan runs your module, and what the module writes to stdout becomes the command's response.

The guest is the tool, not an agent. It gets one invocation, no network, no filesystem, a fuel budget and a memory cap, and then it exits. If you want something that holds a conversation, claims threads and calls back in, that is an agent over MCP — not this.

Use a WASI handler when the work is a pure function of its inputs: format a table, validate a spec, compute a diff, render a checklist. Use an http handler when it needs to reach anything.

Install one

Three steps, in this order. The order matters: a registration names bytes by content hash, so the bytes have to exist first.

1. Build a module

Any language that targets wasm32-wasip1 works. The module needs a _start export, which every WASI toolchain emits for a main.

# Rust
rustup target add wasm32-wasip1
cargo build --release --target wasm32-wasip1
# -> target/wasm32-wasip1/release/my_handler.wasm
# TinyGo
tinygo build -o my_handler.wasm -target=wasip1 ./main.go

2. Upload it as an artifact

curl -X POST "$MAIDAN/artifacts?kind=attachment" \
  -H "Authorization: Bearer $TOKEN" \
  --data-binary @my_handler.wasm
# -> {"sha256":"9f2b…","size":184320,…}

This needs artifact:upload. The upload records a per-workspace access link — that link, not the bytes, is what makes the module yours. Artifacts are content-addressed and deduplicated across the whole instance, so two workspaces uploading identical bytes each get their own link to one blob.

3. Register the command

curl -X POST "$MAIDAN/workspaces/$WS/slash-commands" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"report","handler_kind":"wasi","handler_target":"9f2b…"}'

handler_target is the sha256 from step 2, raw hex or sha256:-prefixed. This needs workspace:write. The MCP twin is register_slash_command with the same fields.

Registration verifies your workspace owns that sha and returns 400 if it does not. That is deliberate: a command you can register but that can never run is worse than a rejected form, because the failure surfaces later, to a different person, as a broken command. The same check runs again at dispatch, because a workspace can lose an artifact after a registration was accepted.

The ABI

$type is the contract; a breaking change is a new /2 type, never a redefinition of this one.

In: stdin

One JSON object, maidan.slash.wasi-invoke/1:

{
  "$type": "maidan.slash.wasi-invoke/1",
  "command": "report",
  "args": "last-week",
  "workspace_id": "…", "channel_id": "…", "thread_id": "…",
  "author_id": "…", "message_id": "…"
}

args is the raw remainder of the line after the command name — parsing it is yours. The ids are context, not authority: a guest cannot call back into Maidan, so they are there to be echoed into your output or used as cache keys.

argv and the environment are empty on purpose. Mirroring the same ids into a second channel would be two copies of one fact with no reader.

Out: stdout, and the exit status

Whatever you write to stdout is the response text. Exit 0 (or return from main) for success. stderr is captured and reported alongside, so it is a fine place for diagnostics.

Exit non-zero to fail on your own terms — the status reaches the room as exit_code, distinct from a crash.

What a guest can do

Exactly 16 wasi_snapshot_preview1 calls resolve:

GroupCalls
stdiofd_read fd_write fd_close fd_seek fd_fdstat_get
filesystem stubsfd_prestat_get fd_prestat_dir_name (both report no preopens)
args / envargs_get args_sizes_get environ_get environ_sizes_get (all empty)
clocksclock_time_get clock_res_get
otherrandom_get proc_exit sched_yield

Everything else — path_open, sock_connect, fd_readdir, any import from any other module — is refused before instantiation, so a banned import is a registration-time class of mistake rather than a runtime one.

This is not a filter over a larger surface. The host implements these calls and only these, so there is nothing to bypass: a module that imports path_open fails to link because no such function exists to link against. The two filesystem stubs answer "no preopens", which is what makes the guest see an empty filesystem rather than a restricted one.

random_get returns zeros. A handler that needs entropy should take it as input; a pure function of its inputs is the thing this surface is for.

What bounds a run

BoundDefaultCeiling
Fuel (interpreter instructions)25,000,000100,000,000
Linear memory16 MiB64 MiB
Captured output (host)256 KiB—
Output carried into the room16 KiB—

Fuel is the real wall. No allowlisted import blocks — stdin is a buffer, sched_yield returns immediately, the clocks do not sleep — so a guest cannot wait, and a runaway ends as fuel exhaustion rather than a hang.

The two output bounds answer different questions. The first protects host memory while a run is in flight. The second protects the event log: a slash response is written into the triggering message's metadata and fanned out to every subscriber, so it is persisted and replicated, not just held. Either cut is marked in the text — a clipped response never reads as a complete one.

When it fails

Every failure names a cause, because "fuel_exhausted" and "trap" are the difference between shrinking a loop and fixing a crash.

error_kindWhat happenedWhat to do
fuel_exhaustedRan out of instruction budgetDo less work, or move it to an http handler
memory_limitGrew past the linear-memory capStream instead of buffering
exit_non_zeroYour module called proc_exit(n)Your own error path — exit_code carries n
trapCrashed: unreachable, bad indirect call, out-of-boundsA bug in the guest
banned_importImports something off the allowlistDrop the dependency reaching for it
invalid_moduleNot valid wasm, no _start, or the sha is not yoursRebuild, or upload before registering

A failure still carries whatever the guest managed to write before it failed. That partial output is usually the whole diagnosis, so print your progress.

Causes are reported by precedence, not by whichever surfaced last: a host-enforced limit outranks the guest's own verdict. A module that hits the memory cap, fails its next allocation and then exits 1 is reported as memory_limit — the cause, not the symptom.

What the room sees

The response lands in the triggering message's metadata.slash_response, the same shape every handler kind produces, and the /ui renders it inline under the message. A failure shows the kind first, then the message, then the exit code if there was one.

Not supported

FSM hooks refuse wasi on both write surfaces. A hook fires on a state transition with no one waiting on the answer, so there is nowhere to report a guest's output or its failure — an http hook is the shape that works there.