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). data is what your code sees programmatically; llmOverride is 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_result and the override is llm_override. The wasm-bindgen ChatMessage class additionally exposes .toolCallId and .name getters 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,
});
OptionTypeDefaultDescription
toolConcurrencynumber0Max concurrent tool calls per round (0 = unlimited)
maxIterationsnumber10Maximum tool-calling rounds
systemPromptstringSystem prompt prepended to the conversation
temperaturenumberSampling temperature
maxTokensnumberMax tokens per completion call
addFinishToolbooleanfalseAdd 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