Other languages (ABI)
The ABI, for writing a package in any language.
The SDKs are conveniences. Any language that builds a WebAssembly module for WASI preview 1 can write a function package by speaking ABI 1 directly — C and C++ with wasi-sdk, Zig, AssemblyScript, TinyGo, Swift, a language of your own.
ABI 1 is small: the module is an MCP server over MCP’s stdio transport, on the stateless revision 2026-07-28, and it may import one host module for HTTP.
The module
| Requirement | |
|---|---|
| Kind | A WebAssembly core module (not a component) |
| Target | WASI preview 1 — wasm32-wasip1, or wasm32-wasi in older toolchains |
| Exports | _start, taking and returning nothing (a WASI command), and its linear memory as memory |
| Imports | Only from wasi_snapshot_preview1 and subako_http |
| Features | WebAssembly 2.0 without GC: bulk memory, reference types, multi-value, sign extension, non-trapping float-to-int, and 128-bit SIMD. Threads, shared memory, exceptions, tail calls, memory64, multi-memory, and relaxed SIMD are not available. |
Upload checks all of this, and names what it refused: an import Subako does not provide, a missing export, a component instead of a module.
A call, from start to end
Subako starts the module
It instantiates the module and calls _start, with argv of one entry
and no environment variables. The first request is already waiting on
stdin.
The module answers each request
Requests arrive on stdin, one JSON-RPC 2.0 message per line. Answer
each on stdout, as one line of compact JSON ending in \n, and flush.
Subako sends one request at a time; answer it as it arrives, because
stdin stays open until the last request is answered.
Subako closes stdin
Once the last request is answered, stdin reaches end of file. The module
must then exit with status 0 — return from _start, or proc_exit(0) —
within one second.
Stderr is free for logs. Stdout is the protocol alone: any line on it that is not a JSON-RPC message breaks the call.
The protocol
Every request carries the protocol revision and the client’s capabilities in _meta. There is no initialize handshake.
{"jsonrpc":"2.0","id":"call-0192f5c4","method":"tools/call","params":{"name":"greet","arguments":{"name":"Ada"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"subako","version":"0.2.3"},"io.modelcontextprotocol/clientCapabilities":{}}}}
Echo each request’s id in its response. Ids are opaque — a string or a number — and differ every time.
When a package is uploaded
Subako runs the module once to learn its tools, with 10 seconds to answer, no volume, no network, and only /package mounted.
-
server/discover— answer the revisions you speak and that you offer tools:{"jsonrpc":"2.0","id":"discover-1","result":{"supportedVersions":["2026-07-28"],"capabilities":{"tools":{}}}} -
tools/list— answer every tool. AnextCursorasks for another page, up to 16 pages.{"jsonrpc":"2.0","id":"tools-list-1","result":{"tools":[{"name":"greet","description":"Greet someone by name.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Who to greet."}},"required":["name"]}}]}} -
Stdin closes; exit 0.
What tools/list answers is what the package offers, until its next version:
| Rule | |
|---|---|
| Tools | 1 to 64, with unique names |
name |
1 to 64 characters: a–z, 0–9, _, - |
description |
Up to 1,024 characters. The model reads it. |
inputSchema |
A JSON Schema with "type": "object" at the top, up to 64 KiB |
outputSchema |
Optional, up to 64 KiB. Shown to people, not the model. |
| All tools together | Up to 256 KiB |
When the agent calls a tool
One tools/call with the tool’s name and its arguments, an object. Answer with an MCP CallToolResult:
{"jsonrpc":"2.0","id":"call-0192f5c4","result":{"content":[{"type":"text","text":"{\"message\":\"Hello, Ada!\"}"}],"structuredContent":{"message":"Hello, Ada!"}}}
Subako does not check arguments against your schema, so validate them yourself. A failure the model should read is a result with isError, never a JSON-RPC error:
{"jsonrpc":"2.0","id":"call-0192f5c4","result":{"content":[{"type":"text","text":"name must not be empty"}],"isError":true}}
The content blocks Subako reads:
| Block | Shown to the model as |
|---|---|
{"type": "text", "text": ...} |
The text |
{"type": "image", "mimeType": "image/png", "data": "<base64>"} |
The image, counted against stdout’s 1 MiB |
{"type": "resource_link", "uri": "volume:///tmp/chart.png", "name": "chart.png"} |
The image file at /volume/tmp/chart.png, read after the module exits |
A volume:/// URI is the path beneath /volume, each segment percent-encoded. Images are PNG, JPEG, GIF, or WebP. Any other resource_link is shown as a line naming its link, and any other block as a note that it was left out.
What breaks a call
The model is told the function crashed, with the last 4 KiB of stderr, when the module:
- answers with a JSON-RPC
error, or aresultTypeother thancomplete; - prints a line on stdout that is not a JSON-RPC message, or answers an id it was not asked;
- sends a request of its own — a stateless server sends none;
- exits before answering, exits non-zero, or does not exit within a second of stdin closing;
- traps, or runs out of memory or time.
Notifications — messages without an id — are ignored.
Files
The filesystem is a set of WASI preopens, in this order: /volume/tmp, then /volume/data when the agent enables it, then /package when the package has assets, then /skills and each /skills/<name>. Open paths by their absolute names.
fd_allocate answers ENOTSUP. Writes to files past the call’s 1 GiB write budget answer EDQUOT. The sock_* functions are linked but never handed a socket: HTTP goes through subako_http.
HTTP: subako_http
Two imports from the module subako_http, on i32 values:
| Import | Does |
|---|---|
request(ptr, len) -> len |
Reads a JSON request of len bytes at ptr, sends it, holds the JSON answer, and returns its length. Blocks until the answer is in. |
read_response(ptr, len) -> 0 |
Copies the held answer to ptr. len must be what request returned. Returns 0, and forgets the answer. |
A negative return is a misuse of the ABI: -1, a range outside the module’s memory; -2, no answer held; -3, a length that does not match.
__attribute__((import_module("subako_http"), import_name("request")))
int32_t subako_http_request(const uint8_t *ptr, int32_t len);
__attribute__((import_module("subako_http"), import_name("read_response")))
int32_t subako_http_read_response(uint8_t *ptr, int32_t len);
The request, up to 8 MiB, takes no fields but these:
{
"method": "POST",
"url": "https://api.example.com/orders",
"headers": { "accept": "application/json" },
"body": { "type": "json", "json": { "order": 1042 } },
"output": { "type": "inline" },
"follow_redirects": true,
"timeout_ms": 5000
}
| Field | |
|---|---|
method |
GET (the default), HEAD, POST, PUT, PATCH, DELETE, or OPTIONS |
url |
Required. https only. |
headers |
Lowercase names. Connection headers such as host and content-length are dropped. |
body |
{"type": "text", "text": ...}, {"type": "json", "json": ...}, or {"type": "file", "path": "/volume/tmp/in.bin"} |
output |
{"type": "inline"} (the default), or {"type": "file", "path": "/volume/tmp/out.bin"} to save the body to the volume |
follow_redirects |
true by default, up to 10 hops |
timeout_ms |
Bounds the whole request. The call’s own timeout applies either way. |
The answer is {"ok": ...} or {"error": ...}:
{"ok":{"status":200,"headers":{"content-type":"application/json"},"body":{"type":"inline","text":"{\"status\":\"shipped\"}"}}}
{"ok":{"status":200,"headers":{},"body":{"type":"file","path":"/volume/tmp/out.bin","bytes":48211}}}
{"error":{"kind":"host_not_allowed","message":"api.example.com is not in this function's network allowlist"}}
An inline body is at most 256 KiB, and must be UTF-8 text; otherwise the request fails with too_large or not_text, and should save to a file instead. The error kinds are invalid_request, host_not_allowed, forbidden_destination, credential_header, credential_unavailable, too_many_redirects, timeout, too_large, not_text, write_budget, bad_path, connection, and abi.
Testing locally
A module can be tried on your machine in any runtime for WASI preview 1 commands: give it one request on stdin and read its answer on stdout.
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}},"name":"greet","arguments":{"name":"Ada"}}}'
The runtime must provide subako_http for the module to load. The Rust and Go templates ship tests/subako_http_stub.wat, a stub whose every request fails, for runtimes that can link one module into another:
(module
(func (export "request") (param i32 i32) (result i32) i32.const -1)
(func (export "read_response") (param i32 i32) (result i32) i32.const -2))
Checklist
- Build a WASI preview 1 command module exporting
_startandmemory, without threads or exceptions. - Read stdin a line at a time; at end of file, exit 0.
- Ignore messages without an
id. - Answer
server/discoverwith"supportedVersions": ["2026-07-28"]and atoolscapability. - Answer
tools/listwith your tools and their object input schemas. - Answer
tools/callwith aCallToolResult, usingisErrorfor failures. - Write one compact JSON line per answer to stdout, and flush. Log to stderr.
- Read and write files under
/volume/tmp,/volume/data,/package, and/skills. - Send HTTP through
subako_http.
Then pack module.wasm with a subako-package.json whose abi is 1.