WASM Agent
Run AI agents with tool calling in WebAssembly
Overview
The runAgent function executes an agentic tool-calling loop entirely within the WASM module. The model decides which tools to call, the WASM runtime invokes each tool’s JavaScript handler function, feeds the result back, and repeats until the model finishes or maxIterations is reached.
Basic agent
Each tool object is { name, description, parameters, handler }. The handler is called with the parsed argument object and may be sync or async.
import init, { CompletionModel, ChatMessage, runAgent } from '@blazen/sdk';
await init();
// The WASM SDK reads OPENAI_API_KEY from the runtime environment.
const model = CompletionModel.openai();
const tools = [
{
name: 'getWeather',
description: 'Get the current weather for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
handler: async (args) => {
// Bare value: dispatcher wraps as { data: <value>, llm_override: null }.
return { temp: 22, condition: 'cloudy', city: args.city };
},
},
];
const result = await runAgent(
model,
[ChatMessage.user('What is the weather in Tokyo?')],
tools,
{ maxIterations: 5 },
);
console.log(result.content);
console.log(`Iterations: ${result.iterations}`);
Tool handlers
A tool’s handler is called with the parsed argument object that matches the tool’s JSON Schema. It must return one of the following:
- A bare value. Anything JSON-serializable — string, number, object, array. The WASM dispatcher wraps it automatically as a
ToolOutput { data: <value>, llm_override: null }. - A structured
ToolOutput. A literal{ data, llmOverride? }object (or snake-cased{ data, llm_override? }— both are accepted).datais what your code sees programmatically;llmOverrideis what gets sent back to the model on the next turn.
const tools = [
{
name: 'search',
description: 'Search the documentation',
parameters: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
handler: async (args) => {
const hits = await fetch(`/api/search?q=${args.query}`).then((r) => r.json());
// Bare-value return: hits flow through to the model verbatim.
return hits;
},
},
{
name: 'calculate',
description: 'Evaluate an arithmetic expression',
parameters: {
type: 'object',
properties: { expression: { type: 'string' } },
required: ['expression'],
},
handler: (args) => ({ result: eval(args.expression) }),
},
];
If a tool throws (or rejects), the runtime surfaces the error as a tool-result message containing the error text, allowing the model to recover. If the handler returns nothing (undefined), the dispatcher records an empty result.
Structured tool results
For larger payloads, return a ToolOutput literal so the caller-visible data and the model-visible override can diverge. Both llmOverride (camelCase) and llm_override (snake_case) are accepted by the dispatcher; the spelling is normalized before deserialization.
const tools = [
{
name: 'fetchProfile',
description: 'Fetch a full user profile',
parameters: {
type: 'object',
properties: { userId: { type: 'string' } },
required: ['userId'],
},
handler: async (args) => {
const profile = await db.users.findById(args.userId);
return {
// Caller (your application code) gets the full record.
data: profile,
// Model sees a compact text summary on the next turn.
llmOverride: {
kind: 'text',
text: `User ${profile.name} (id=${profile.id}, ${profile.role})`,
},
};
},
},
];
The LlmPayload shape used by llmOverride has four variants — see the WASM API reference for the full table:
type LlmPayload =
| { kind: 'text'; text: string }
| { kind: 'json'; value: any }
| { kind: 'parts'; parts: ContentPart[] }
| { kind: 'provider_raw'; provider: ProviderId; value: any };
Inspecting tool_result on returned messages
After runAgent resolves, every entry in result.messages matches the tsify-generated ChatMessage interface (snake_case fields). Tool-result messages carry a tool_result?: ToolOutput whenever the handler returned a non-string data or supplied an llm_override:
const result = await runAgent(model, messages, tools, { maxIterations: 5 });
for (const msg of result.messages) {
if (msg.role !== 'tool') continue;
if (msg.tool_result) {
// Structured payload: full data plus optional override.
console.log('tool', msg.name, 'returned', msg.tool_result.data);
if (msg.tool_result.llm_override) {
console.log(' (model saw:', msg.tool_result.llm_override, ')');
}
} else {
// Plain string return: the result lives in content as text.
console.log('tool', msg.name, 'returned', msg.content);
}
}
Field naming. The tsify-generated interface preserves Rust snake_case, so the field is
tool_resultand the override isllm_override. The wasm-bindgenChatMessageclass additionally exposes.toolCallIdand.namegetters in camelCase.
Agent options
Pass an options object as the fourth argument:
const result = await runAgent(model, messages, tools, {
toolConcurrency: 2,
maxIterations: 5,
systemPrompt: 'You are a helpful research assistant.',
temperature: 0.3,
maxTokens: 1000,
addFinishTool: true,
});
| Option | Type | Default | Description |
|---|---|---|---|
toolConcurrency | number | 0 | Max concurrent tool calls per round (0 = unlimited) |
maxIterations | number | 10 | Maximum tool-calling rounds |
systemPrompt | string | — | System prompt prepended to the conversation |
temperature | number | — | Sampling temperature |
maxTokens | number | — | Max tokens per completion call |
addFinishTool | boolean | false | Add a built-in “finish” tool the model can call to signal completion |
Structured output
Combine tool calling with a single-purpose tool to extract structured data:
const extractTools = [
{
name: 'extractContact',
description: 'Extract contact information from text',
parameters: {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string' },
phone: { type: 'string' },
},
required: ['name'],
},
handler: async (args) => {
// The model fills `args` with the extracted fields; we just echo
// them back as the tool result so the loop can terminate.
return args;
},
},
];
const result = await runAgent(
model,
[ChatMessage.user('Extract: John Doe, john@example.com, 555-1234')],
extractTools,
{ maxIterations: 1 },
);
// The extracted fields live on the tool-result message.
const toolMsg = result.messages.find((m) => m.role === 'tool');
const extracted = toolMsg?.tool_result?.data ?? JSON.parse(toolMsg?.content ?? '{}');
console.log(extracted);
// { name: "John Doe", email: "john@example.com", phone: "555-1234" }
Agent result
runAgent resolves with an AgentResult:
interface AgentResult {
content?: string; // Final text response
messages: ChatMessage[]; // Full message history (tsify shape)
iterations: number; // Number of tool-calling iterations
totalUsage?: TokenUsage; // Aggregated token usage
totalCost?: number; // Aggregated cost in USD
}
Next steps
- Combine agents with workflows in the WASM Workflows guide.
- Deploy to edge platforms with the Edge Deployment guide.