Machine Learning One

From Library to Application: CLI, REPL, and the WAT Program Library

Turning the runtime into a usable application with a CLI, REPL, and reusable program catalog.

We have an agent that can think, generate WAT, compile it, execute it in a sandbox, observe the results, and loop. But nobody is going to use it by calling Rust functions from another Rust program. This article turns the library into an application: a CLI with three modes of operation (single-shot, REPL, serve) and a catalog of reusable WAT programs that users can invoke directly.

The cli crate is the entry point that ties everything together. Its Cargo.toml pulls in the agent crate plus dependencies for the CLI, HTTP server, and embedded frontend:

[package]
name = "cli"
version = "0.1.0"
edition = "2021"

[dependencies]
agent = { path = "../agent" }
rt = { path = "../rt" }
tokio = { workspace = true }
clap = { workspace = true }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
axum = { workspace = true }
rand = { workspace = true }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rust-embed = "8"
mime_guess = "2"
tokio-stream = "0.1"
arc-swap = "1"
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
rustyline = "14"

Notable additions compared to previous crates: clap for argument parsing, rustyline for REPL line editing with history, axum + tower-http for the HTTP server (Article 12), rust-embed for embedding the frontend SPA into the binary, arc-swap for atomic router hot-swapping, and tokio-stream for SSE event streaming.

Why Three Modes

A runtime like this serves different use cases:

  1. Single-shot -- pipe a question in, get an answer out. Good for scripting and CI.
  2. REPL -- interactive multi-turn conversation with persistent session state. Good for exploration.
  3. Serve -- HTTP server with SSE streaming and an embedded web UI. Good for deployment.

All three share the same agent configuration, the same capabilities, and the same catalog of programs. The difference is how they feed messages in and how they render events out.

The CLI with Clap

Here is the full main.rs:

mod server;
mod trace;

use agent::{Agent, AgentConfig, AgentEvent};
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use rt::EngineConfig;
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;

#[derive(Parser)]
#[command(name = "agent", about = "Agentic WASM runtime")]
struct Cli {
    /// User message to process (omit for REPL mode)
    message: Option<String>,

    /// Session ID for multi-turn conversation
    #[arg(short, long)]
    session: Option<String>,

    #[command(flatten)]
    agent: AgentArgs,

    #[command(subcommand)]
    command: Option<Commands>,
}

#[derive(Subcommand)]
enum Commands {
    /// Start the HTTP server with web UI
    Serve {
        /// Port to listen on
        #[arg(short, long, default_value_t = 8080)]
        port: u16,

        /// Run in API-only mode (CORS enabled, no embedded SPA)
        #[arg(long)]
        api: bool,

        /// Allowed CORS origin port (for frontend dev server)
        #[arg(long, default_value_t = 5173)]
        allow_origin: u16,
    },
}

#[derive(Args)]
struct AgentArgs {
    /// Path to WAT catalog directory
    #[arg(short = 'l', long = "catalog")]
    catalog: Option<PathBuf>,

    /// Filesystem root for fs.* capabilities
    #[arg(long)]
    fs_root: Option<PathBuf>,

    /// Maximum memory in MB
    #[arg(long, default_value = "16")]
    max_memory_mb: u64,

    /// Fuel limit (instruction count)
    #[arg(long)]
    fuel: Option<u64>,

    /// Maximum retries on compile error
    #[arg(long, default_value = "2")]
    max_retries: usize,

    /// Maximum ReAct loop steps
    #[arg(long, default_value = "10")]
    max_steps: usize,

    /// Enable content safety guard (pre/post-processing)
    #[arg(long)]
    guard: bool,

    /// Directory for session trace files (enables file-based tracing)
    #[arg(long)]
    trace_dir: Option<PathBuf>,
}

fn init_tracing(dir: &Path) -> Result<()> {
    use tracing_subscriber::prelude::*;
    use tracing_subscriber::EnvFilter;

    let session_layer = trace::SessionFileLayer::new(dir)
        .map_err(|e| anyhow::anyhow!("Failed to initialize tracing: {e}"))?;

    let filter = EnvFilter::try_from_env("AGENT_TRACE")
        .unwrap_or_else(|_| EnvFilter::new("info"));

    tracing_subscriber::registry()
        .with(filter)
        .with(session_layer)
        .init();

    Ok(())
}

fn build_agent(cli: &Cli) -> Result<Agent> {
    let api_key =
        std::env::var("CEREBRAS_API_KEY").expect("CEREBRAS_API_KEY environment variable not set");

    let config = AgentConfig {
        api_key,
        model: None,
        engine_config: EngineConfig {
            max_memory_bytes: cli.agent.max_memory_mb * 1024 * 1024,
            fuel: cli.agent.fuel,
            ..Default::default()
        },
        http_allowed_domains: None,
        http_timeout: Duration::from_secs(30),
        fs_root: cli.agent.fs_root.clone(),
        session_ttl: Duration::from_secs(3600),
        max_retries: cli.agent.max_retries,
        max_steps: cli.agent.max_steps,
        catalog_path: cli.agent.catalog.clone(),
        guard_enabled: cli.agent.guard,
    };

    Agent::new(config)
}

/// Parse a slash command from input. Returns `(name, args)` if input starts with `/`.
/// Supports shell-style quoting: `"multi word arg"`, `'literal'`, and `\n` escapes in
/// double-quoted strings, so `/cmd "arg with spaces"` passes one argument correctly.
pub(crate) fn parse_slash_command(input: &str) -> Option<(String, Vec<String>)> {
    let rest = input.strip_prefix('/')?;
    let tokens = shell_split(rest);
    let mut iter = tokens.into_iter();
    let name = iter.next()?;
    let args: Vec<String> = iter.collect();
    Some((name, args))
}

/// Minimal shell-style tokeniser: splits on unquoted whitespace, honours `"..."` and
/// `'...'` quoting, and processes `\n`, `\t`, `\\`, `\"` escapes inside double quotes.
fn shell_split(s: &str) -> Vec<String> {
    let mut tokens: Vec<String> = Vec::new();
    let mut current = String::new();
    let mut in_single = false;
    let mut in_double = false;
    let mut chars = s.chars().peekable();

    while let Some(c) = chars.next() {
        match c {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            '\\' if in_double => match chars.next() {
                Some('n') => current.push('\n'),
                Some('t') => current.push('\t'),
                Some('"') => current.push('"'),
                Some('\\') => current.push('\\'),
                Some(other) => { current.push('\\'); current.push(other); }
                None => current.push('\\'),
            },
            c if c.is_whitespace() && !in_single && !in_double => {
                if !current.is_empty() {
                    tokens.push(std::mem::take(&mut current));
                }
            }
            c => current.push(c),
        }
    }
    if !current.is_empty() {
        tokens.push(current);
    }
    tokens
}

/// Consume agent events from `rx`, printing progress to stderr. Returns the final response text.
async fn stream_and_print(rx: &mut mpsc::Receiver<AgentEvent>) -> Option<String> {
    let mut final_text = None;
    while let Some(event) = rx.recv().await {
        match event {
            AgentEvent::Thinking { content, step } => {
                let lines: Vec<&str> = content.lines().collect();
                let show = lines.len().min(10);
                for (i, line) in lines[..show].iter().enumerate() {
                    let line = line.trim();
                    if i == 0 {
                        eprintln!("\x1b[2m[step {}]\x1b[0m \x1b[2;36m{}\x1b[0m", step + 1, line);
                    } else {
                        eprintln!("         \x1b[2;36m{}\x1b[0m", line);
                    }
                }
                if lines.len() > 10 {
                    eprintln!("         \x1b[2m... ({} more lines)\x1b[0m", lines.len() - 10);
                }
            }
            AgentEvent::ToolStart { label, step, source, description } => {
                if let Some(ref desc) = description {
                    eprintln!(
                        "\x1b[2m[step {}]\x1b[0m \x1b[33m> {}\x1b[0m \x1b[36m-- {}\x1b[0m",
                        step + 1, label, desc
                    );
                } else {
                    eprintln!("\x1b[2m[step {}]\x1b[0m \x1b[33m> {}\x1b[0m", step + 1, label);
                }
                if let Some(ref src) = source {
                    let lines: Vec<&str> = src.lines().collect();
                    let show = lines.len().min(30);
                    for line in &lines[..show] {
                        eprintln!("         \x1b[2;34m{}\x1b[0m", line);
                    }
                    if lines.len() > 30 {
                        eprintln!("         \x1b[2m... ({} lines total)\x1b[0m", lines.len());
                    }
                }
            }
            AgentEvent::ToolResult {
                success,
                output,
                step,
                source,
            } => {
                let (icon, color) = if success { ("OK", "32") } else { ("FAIL", "31") };
                let line = output.lines().next().unwrap_or("").trim();
                let truncated = if line.len() > 80 { &line[..80] } else { line };
                eprintln!(
                    "\x1b[{}m[step {}] {} {}: {}\x1b[0m",
                    color,
                    step + 1,
                    icon,
                    source,
                    truncated
                );
            }
            AgentEvent::Response { text, .. } => final_text = Some(text),
            AgentEvent::Error { message } => eprintln!("\x1b[31mError: {}\x1b[0m", message),
            _ => {}
        }
    }
    final_text
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    if let Some(ref trace_dir) = cli.agent.trace_dir {
        init_tracing(trace_dir)?;
    }

    match &cli.command {
        Some(Commands::Serve {
            port,
            api,
            allow_origin,
        }) => {
            let agent = build_agent(&cli)?;
            server::run(*port, *api, *allow_origin, agent).await
        }
        None => {
            let agent = Arc::new(build_agent(&cli)?);

            if let Some(ref message) = cli.message {
                // Single-shot mode
                let (tx, mut rx) = mpsc::channel(32);
                if let Some((cmd, args)) = parse_slash_command(message) {
                    let agent_arc = agent.clone();
                    let session = cli.session.clone();
                    tokio::spawn(async move {
                        agent_arc.command_stream(session.as_deref(), &cmd, args, tx).await;
                    });
                } else {
                    let agent_arc = agent.clone();
                    let session = cli.session.clone();
                    let message = message.clone();
                    tokio::spawn(async move {
                        agent_arc.process_stream(session.as_deref(), &message, tx).await;
                    });
                }
                if let Some(text) = stream_and_print(&mut rx).await {
                    println!("{}", text);
                }
            } else {
                // REPL mode
                let session_id = cli.session.clone().unwrap_or_else(|| "repl".to_string());
                let mut rl = DefaultEditor::new()?;
                let mut turn = 0u32;
                println!("Agentic WASM Runtime (Ctrl-D or /exit to quit)");
                loop {
                    turn += 1;
                    let prompt = format!("[{}] > ", turn);
                    match rl.readline(&prompt) {
                        Ok(line) => {
                            let input = line.trim().to_string();
                            if input.is_empty() {
                                turn -= 1;
                                continue;
                            }
                            let _ = rl.add_history_entry(&input);

                            if let Some((cmd, args)) = parse_slash_command(&input) {
                                match cmd.as_str() {
                                    "exit" | "quit" => break,
                                    _ => {
                                        let (tx, mut rx) = mpsc::channel(32);
                                        let agent_arc = agent.clone();
                                        let session = session_id.clone();
                                        tokio::spawn(async move {
                                            agent_arc
                                                .command_stream(Some(&session), &cmd, args, tx)
                                                .await;
                                        });
                                        if let Some(text) = stream_and_print(&mut rx).await {
                                            println!("{}", text);
                                        }
                                    }
                                }
                            } else {
                                let (tx, mut rx) = mpsc::channel(32);
                                let agent_arc = agent.clone();
                                let session = session_id.clone();
                                let input = input.clone();
                                tokio::spawn(async move {
                                    agent_arc
                                        .process_stream(Some(&session), &input, tx)
                                        .await;
                                });
                                if let Some(text) = stream_and_print(&mut rx).await {
                                    println!("{}", text);
                                }
                            }
                        }
                        Err(ReadlineError::Eof) | Err(ReadlineError::Interrupted) => break,
                        Err(e) => eprintln!("Error: {e}"),
                    }
                }
            }

            Ok(())
        }
    }
}

The Clap Structure

The top-level Cli struct has three pieces:

  • message: Option<String> -- if present, run single-shot. If absent, run REPL.
  • command: Option<Commands> -- if Some(Serve { .. }), start the HTTP server instead.
  • agent: AgentArgs -- shared configuration flattened into the top-level parser.

The AgentArgs struct uses #[command(flatten)] so its flags are available in every mode:

FlagDefaultPurpose
--catalog (-l)NoneDirectory of .wat library programs
--fs-rootNoneJail directory for fs.* capabilities
--max-memory-mb16WASM linear memory limit
--fuelNoneInstruction count limit (None = unlimited)
--max-retries2Compile error retries before giving up
--max-steps10Maximum ReAct loop iterations
--guardfalseEnable AI content safety guard
--trace-dirNoneDirectory for session trace files

The build_agent function maps these CLI flags into an AgentConfig and constructs the Agent. This is the single point where all runtime parameters converge.

Mode Selection

The mode selection logic is simple:

if cli.command == Some(Serve { .. })  =>  HTTP server
else if cli.message.is_some()         =>  Single-shot
else                                  =>  REPL

Both single-shot and REPL share the same event streaming infrastructure. The server gets its own module (covered in the next article).

Single-Shot Mode

Single-shot is the simplest path. Given a message, create an mpsc::channel, spawn the agent on a background task, and drain events from the receiver:

let (tx, mut rx) = mpsc::channel(32);
let agent_arc = agent.clone();
let message = message.clone();
tokio::spawn(async move {
    agent_arc.process_stream(session.as_deref(), &message, tx).await;
});
if let Some(text) = stream_and_print(&mut rx).await {
    println!("{}", text);
}

The agent runs on a spawned task because process_stream is async and may take seconds (it calls the LLM, compiles WASM, executes it, possibly multiple times). The main task consumes events as they arrive and renders them to stderr. The final response text goes to stdout.

This separation matters for scripting: progress indicators and thinking output go to stderr (so they vanish when you pipe), while the answer goes to stdout.

Slash commands work in single-shot too. If the message starts with /, it is parsed and dispatched to command_stream instead of process_stream, executing a catalog program directly without involving the LLM.

The REPL

The REPL uses rustyline for line editing with history. The session ID defaults to "repl" so that KV state and conversation history persist across turns within the same run.

let session_id = cli.session.clone().unwrap_or_else(|| "repl".to_string());
let mut rl = DefaultEditor::new()?;
let mut turn = 0u32;
println!("Agentic WASM Runtime (Ctrl-D or /exit to quit)");

Each turn reads a line, skips empty input, adds to history, and dispatches:

  1. If input starts with /exit or /quit, break the loop.
  2. If input starts with /, parse it as a slash command and call command_stream.
  3. Otherwise, send to process_stream for the full ReAct loop.

The event rendering is identical to single-shot -- stream_and_print handles both.

Slash Command Parsing

The parse_slash_command function strips the leading / and tokenizes the rest using a minimal shell-style splitter:

pub(crate) fn parse_slash_command(input: &str) -> Option<(String, Vec<String>)> {
    let rest = input.strip_prefix('/')?;
    let tokens = shell_split(rest);
    let mut iter = tokens.into_iter();
    let name = iter.next()?;
    let args: Vec<String> = iter.collect();
    Some((name, args))
}

The shell_split function handles three quoting styles:

  • Unquoted: whitespace splits tokens. /cmd arg1 arg2 gives ["cmd", "arg1", "arg2"].
  • Double quotes: "multi word arg" is one token. Escape sequences \n, \t, \\, \" are processed.
  • Single quotes: 'literal string' is one token. No escape processing.

This matters because catalog programs accept arguments, and those arguments are often natural language strings with spaces. /ai_summarize "The quick brown fox jumped over the lazy dog" must pass one argument, not nine.

When a slash command like /github_search_repos "async runtime language:rust" is entered, it bypasses the LLM entirely. The agent looks up github_search_repos in the catalog, fills in missing arguments with defaults from the TOML frontmatter, compiles and executes the WAT program directly, and streams the result back. This is fast -- no LLM round-trip, no ReAct loop.

Event Streaming

Both modes feed events through the same pipeline: an mpsc::channel<AgentEvent> from the agent task to the rendering function stream_and_print.

The AgentEvent enum has six variants:

pub enum AgentEvent {
    Thinking { step: usize, content: String },
    ToolStart {
        step: usize,
        label: String,
        source: Option<String>,
        description: Option<String>,
    },
    ToolResult {
        step: usize,
        source: String,
        success: bool,
        output: String,
    },
    Retry { step: usize, attempt: usize, error: String },
    Response { text: String, session_id: String, steps: usize },
    Error { message: String },
}

The stream_and_print function maps each variant to terminal output:

EventRendering
ThinkingDim cyan text, first 10 lines, step number prefix
ToolStartYellow label with optional description and source preview (30 lines max)
ToolResultGreen for success, red for failure, first 80 chars of output
ResponseStored as return value, printed to stdout by caller
ErrorRed text to stderr
RetrySilently ignored (the _ => {} catch-all)

Progress goes to stderr, final answer to stdout. This is a deliberate Unix convention: cargo run -p cli -- "summarize this" > output.txt captures the answer cleanly while showing progress in the terminal.

Library Program Walkthrough

Library programs are pre-built .wat files with TOML frontmatter. They live in a catalog directory (typically agent/programs/) and are loaded at startup when --catalog is specified. Each program declares its name, description, and arguments. The agent can invoke them via slash commands (bypassing the LLM) or the LLM can reference them with @use syntax.

Let's walk through two programs in detail.

Program 1: ai_summarize.wat

This program summarizes text of any length by chunking it and recursively summarizing pieces. It demonstrates loops, arrays, AI calls, and progress tracking.

The Frontmatter

;;; name = "ai_summarize"
;;; description = "Summarize large text by chunking and recursively summarizing pieces."
;;; [[args]]
;;; name = "text"
;;; type_hint = "string"
;;; description = "The text to summarize (can be very large)"
;;; [[args]]
;;; name = "system_prompt"
;;; type_hint = "string"
;;; description = "System instruction for per-chunk summarization"
;;; default = "You are a summarization assistant. Summarize the following text concisely, preserving key facts and main points. Return only the summary."
;;; [[args]]
;;; name = "combine_prompt"
;;; type_hint = "string"
;;; description = "System instruction for combining chunk summaries"
;;; default = "You are a summarization assistant. The following are summaries of different sections of a document. Combine them into a single coherent summary that preserves all key information. Return only the combined summary."

Three arguments, but only the first is required. The second and third have sensible defaults that the agent fills in automatically when missing.

The Full Program

(local $text_len i32)
(local $len_err i32)
(local $arr_id i32)
(local $arr_err i32)
(local $arr_length i32)
(local $i i32)
(local $chunk_ptr i32)
(local $chunk_err i32)
(local $sum_ptr i32)
(local $sum_err i32)
(local $results_id i32)
(local $joined_ptr i32)
(local $joined_err i32)
(local $task_summarize i32)
(local $task_combine i32)

;; === Load inputs ===

(argv 0 $text_ptr)       ;; Load text argument into $text_ptr
(argv 1 $sys_ptr)        ;; Load system prompt into $sys_ptr
(argv 2 $combine_ptr)    ;; Load combine prompt into $combine_ptr

;; === Check if text is short enough for direct summarization ===

(call $str.len (local.get $text_ptr))   ;; Returns (length, error)
(local.set $len_err)
(local.set $text_len)

(if (i32.lt_u (local.get $text_len) (i32.const 6000))
    (then
        ;; Short text: summarize directly (single unbounded task)
        (local.set $task_summarize (call $dbg.task "Summarizing" (i32.const 1)))
        (call $ai.assist (local.get $text_ptr) (local.get $sys_ptr) (call $sys.nil))
        (local.set $sum_err)
        (local.set $sum_ptr)
        (check $sum_err)
        (call $dbg.step (local.get $task_summarize))
        (call $dbg.done (local.get $task_summarize) (i32.const 0))
        (resv $sum_ptr)          ;; Store result
        (return (i32.const 0))   ;; Return success
    )
)

The first decision point: if the text is under 6000 bytes, skip chunking and summarize directly. This avoids unnecessary overhead for short inputs. The dbg.task/dbg.step/dbg.done calls emit progress events that the CLI renders.

;; === Chunk the text ===

(call $str.chunk (local.get $text_ptr) (i32.const 4000))
(local.set $arr_err)
(local.set $arr_id)
(check $arr_err)

(local.set $arr_length (call $arr.len (local.get $arr_id)))

For longer text, str.chunk splits it into 4000-byte pieces, returning an array ID. The array lives on the host side -- only individual chunks get materialized into WASM memory when accessed.

;; === Summarize each chunk ===

(local.set $task_summarize (call $dbg.task "Summarizing" (local.get $arr_length)))
(local.set $results_id (call $arr.new))
(local.set $i (i32.const 0))

(block $break
    (loop $loop
        (br_if $break (i32.ge_u (local.get $i) (local.get $arr_length)))

        ;; Get chunk[i] -- materializes into WASM memory
        (call $arr.get (local.get $arr_id) (local.get $i))
        (local.set $chunk_err)
        (local.set $chunk_ptr)
        (check $chunk_err)

        ;; Summarize this chunk
        (call $ai.assist (local.get $chunk_ptr) (local.get $sys_ptr) (call $sys.nil))
        (local.set $sum_err)
        (local.set $sum_ptr)
        (check $sum_err)

        ;; Push summary to results array (host-side)
        (drop (call $arr.push (local.get $results_id) (local.get $sum_ptr)))

        (call $dbg.step (local.get $task_summarize))

        ;; i++
        (local.set $i (i32.add (local.get $i) (i32.const 1)))
        (br $loop)
    )
)

(call $dbg.done (local.get $task_summarize) (i32.const 0))

This is a classic WAT loop: block $break + loop $loop with a conditional branch. For each chunk:

  1. arr.get materializes chunk i into WASM memory and returns a pointer.
  2. ai.assist sends that chunk to the LLM with the summarization prompt.
  3. arr.push stores the summary in a new host-side array.
  4. dbg.step ticks the progress counter.

Every call returns (result, error) on the stack. The (check $err) macro expands to an early return if the error code is non-zero.

;; === Combine summaries ===

(local.set $task_combine (call $dbg.task "Combining" (i32.const 1)))

;; Join all summaries (nil separator = empty string)
(call $arr.join (local.get $results_id) (call $sys.nil))
(local.set $joined_err)
(local.set $joined_ptr)
(check $joined_err)

;; Final combine pass using the combine prompt
(call $ai.assist (local.get $joined_ptr) (local.get $combine_ptr) (call $sys.nil))
(local.set $sum_err)
(local.set $sum_ptr)
(check $sum_err)

(call $dbg.step (local.get $task_combine))
(call $dbg.done (local.get $task_combine) (i32.const 0))

(resv $sum_ptr)
(i32.const 0)

The final stage: join all chunk summaries into one string, then make one more AI call with the combine_prompt to produce a coherent final summary. The (resv $sum_ptr) macro stores the result for the host to retrieve, and (i32.const 0) returns success.

The map-reduce pattern here is deliberate. Summarizing a 50,000-byte document in one pass would exceed context limits. Chunking at 4000 bytes, summarizing each chunk independently, then combining the summaries is a standard technique for handling unbounded input.

Program 2: github_search_repos.wat

This program searches GitHub repositories by keyword and returns structured results. It demonstrates URL construction with str.cat, HTTP fetching, and AI-based JSON extraction.

The Frontmatter

;;; name = "github_search_repos"
;;; description = "Search GitHub repositories by keyword and return top results sorted by stars. Supports GitHub search qualifiers like 'language:rust', 'topic:ml', 'stars:>1000'."
;;; [[args]]
;;; name = "query"
;;; type_hint = "string"
;;; description = "Search query with optional qualifiers (e.g. 'async runtime language:rust', 'transformer stars:>5000')"
;;; [[args]]
;;; name = "per_page"
;;; type_hint = "string"
;;; description = "Number of results to return (max 100)"
;;; default = "5"
;;; [[args]]
;;; name = "extract_prompt"
;;; type_hint = "string"
;;; description = "AI system prompt for extracting repo info from GitHub search JSON"
;;; default = "From this GitHub repository search JSON, extract each repo from items[]. Output:\nREPO: {full_name}\nSTARS: {stargazers_count}\nLANGUAGE: {language or 'Unknown'}\nDESCRIPTION: {description or 'No description'}\nURL: {html_url}\nTOPICS: {first 5 topics joined by comma, or 'none'}\n---\nList repos in order of stars descending."

Again, only the first argument (query) is required. The per_page default is "5" and the extract_prompt default contains a full extraction template.

The Full Program

;; github_search_repos: Search GitHub repos by keyword
;; URL: "https://api.github.com/search/repositories?q=" + query
;;       + "&sort=stars&order=desc&per_page=" + per_page

(local $url_ptr i32) (local $url_err i32)
(local $resp_ptr i32) (local $resp_err i32)
(local $result_ptr i32) (local $result_err i32)

;; Load args
(argv 0 $query_ptr)      ;; The search query
(argv 1 $per_page_ptr)   ;; Number of results
(argv 2 $extract_ptr)    ;; AI extraction prompt

;; Build URL: prefix + query + mid + per_page
(call $str.cat "https://api.github.com/search/repositories?q=" (local.get $query_ptr))
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

(call $str.cat (local.get $url_ptr) "&sort=stars&order=desc&per_page=")
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

(call $str.cat (local.get $url_ptr) (local.get $per_page_ptr))
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

Three str.cat calls build the complete URL. Each takes two string pointers (or a string literal and a pointer) and returns a new pointer to the concatenated string. The URL construction is: "https://api.github.com/search/repositories?q=" + query + "&sort=stars&order=desc&per_page=" + per_page.

Note that inline string literals like "https://..." are preprocessor syntax. The preprocessor inlines them as data sections and replaces the literal with (i32.const offset).

;; Fetch from GitHub
(call $http.get (local.get $url_ptr))
(local.set $resp_err) (local.set $resp_ptr)
(check $resp_err)

;; Parse with AI
(call $ai.assist (local.get $resp_ptr) (local.get $extract_ptr) (call $sys.nil))
(local.set $result_err) (local.set $result_ptr)
(check $result_err)

(resv $result_ptr)
(i32.const 0)

The response from GitHub is raw JSON -- potentially thousands of lines. Rather than parsing JSON in WAT (which would be painful), the program hands the entire response to ai.assist with an extraction prompt. The LLM reads the JSON and outputs a clean, structured format. This is the "AI as parser" pattern: using inference where traditional code would be brittle or verbose.

Comparing the Two Programs

These two programs illustrate the two main patterns in the catalog:

  1. Map-reduce (ai_summarize): chunk input, process each chunk independently, combine results. Requires loops, arrays, and multiple AI calls. About 125 lines.

  2. Fetch-and-extract (github_search_repos): build a URL, fetch data, use AI to extract structure from the response. Linear, no loops. About 55 lines.

The fetch-and-extract pattern appears in several other catalog programs too. Here is arxiv_search.wat, which follows the same structure but targets the ArXiv API:

;;; name = "arxiv_search"
;;; description = "Search ArXiv papers by keyword and return a formatted list of results."

(local $url_ptr i32) (local $url_err i32)
(local $resp_ptr i32) (local $resp_err i32)
(local $result_ptr i32) (local $result_err i32)

(argv 0 $query_ptr)
(argv 1 $limit_ptr)
(argv 2 $extract_ptr)

(call $str.cat "http://export.arxiv.org/api/query?search_query=all:" (local.get $query_ptr))
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

(call $str.cat (local.get $url_ptr) "&max_results=")
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

(call $str.cat (local.get $url_ptr) (local.get $limit_ptr))
(local.set $url_err) (local.set $url_ptr)
(check $url_err)

(call $http.get (local.get $url_ptr))
(local.set $resp_err) (local.set $resp_ptr)
(check $resp_err)

(call $ai.assist (local.get $resp_ptr) (local.get $extract_ptr) (call $sys.nil))
(local.set $result_err) (local.set $result_ptr)
(check $result_err)

(resv $result_ptr)
(i32.const 0)

Once you have seen one fetch-and-extract program, writing another takes minutes. The pattern is mechanical: build URL with str.cat, fetch with http.get, parse with ai.assist, return with resv.

Reader Exercises

Exercise 1: Write wikipedia_get.wat

Write a catalog program that fetches a Wikipedia article summary by title using the Wikipedia REST API (https://en.wikipedia.org/api/rest_v1/page/summary/{title}). It should accept a title argument (with underscores for spaces) and an optional extract_prompt. Follow the fetch-and-extract pattern from github_search_repos.wat. The program should build the URL, fetch the JSON, and use ai.assist to format the output. (Hint: this requires only two str.cat calls since there are no query parameters -- just a path segment.)

Exercise 2: Multi-step fact-checker

Write a catalog program fact_check.wat that takes a claim as input and performs these steps:

  1. Use ai.assist to extract 2-3 key factual assertions from the claim.
  2. For each assertion, search Wikipedia (using http.get on the REST API) to find relevant articles.
  3. Use ai.assist to compare each assertion against the Wikipedia data.
  4. Combine the results into a final verdict: Supported, Partially Supported, or Unsupported.

This is a map-reduce problem similar to ai_summarize but with an HTTP fetch inside the loop. You will need arrays (arr.new, arr.push, arr.join), a loop, and multiple AI calls.

On this page