First Agent
Building Your First Agent
Learn how to create WebAssembly agents for Caxton in your preferred programming language.
Agent Basics
Every Caxton agent must:
- Export a
handle_message
function - Accept FIPA-formatted messages
- Return valid FIPA responses (or null)
- Compile to WebAssembly
Agent Lifecycle
Agents follow a managed lifecycle:
- Deployment: WASM modules are validated and loaded
- Execution: Agents process messages in isolated sandboxes
- Management: Hot reload enables zero-downtime updates
- Monitoring: Resource usage and health are tracked
For production deployment and management, see Agent Lifecycle Management.
JavaScript Agent
Simple Echo Agent
Create echo-agent.js
:
// Echo agent that responds to any message
export function handle_message(message_bytes) {
// Parse the incoming message
const message = JSON.parse(new TextDecoder().decode(message_bytes));
console.log(`[${message.receiver}] Received from ${message.sender}: ${message.content.text}`);
// Create response
const response = {
performative: 'inform',
sender: message.receiver, // Our ID
receiver: message.sender, // Reply to sender
conversation_id: message.conversation_id,
in_reply_to: message.id,
content: {
text: `Echo: ${message.content.text}`,
timestamp: Date.now()
}
};
// Return encoded response
return new TextEncoder().encode(JSON.stringify(response));
}
// Optional: Handle agent initialization
export function init() {
console.log("Echo agent initialized");
return 0; // Success
}
// Optional: Handle agent shutdown
export function shutdown() {
console.log("Echo agent shutting down");
return 0; // Success
}
Compile to WebAssembly
Using Javy:
# Install Javy
npm install -g @bytecodealliance/javy
# Compile to WASM
javy compile echo-agent.js -o echo-agent.wasm
# Deploy to Caxton
caxton deploy echo-agent.wasm --name echo
Advanced: Stateful Agent
// Stateful counter agent
let counter = 0;
const state = new Map();
export function handle_message(message_bytes) {
const message = JSON.parse(new TextDecoder().decode(message_bytes));
switch(message.performative) {
case 'request':
return handleRequest(message);
case 'query':
return handleQuery(message);
case 'subscribe':
return handleSubscribe(message);
default:
return handleUnknown(message);
}
}
function handleRequest(message) {
const { action, params } = message.content;
switch(action) {
case 'increment':
counter += params.amount || 1;
break;
case 'decrement':
counter -= params.amount || 1;
break;
case 'reset':
counter = 0;
break;
}
return createResponse(message, {
status: 'success',
counter: counter
});
}
function handleQuery(message) {
return createResponse(message, {
counter: counter,
state: Object.fromEntries(state)
});
}
function createResponse(originalMessage, content) {
const response = {
performative: 'inform',
sender: originalMessage.receiver,
receiver: originalMessage.sender,
conversation_id: originalMessage.conversation_id,
in_reply_to: originalMessage.id,
content: content
};
return new TextEncoder().encode(JSON.stringify(response));
}
Python Agent
Setup
First, install the Python to WASM compiler:
pip install wasmtime-py pyodide-build
Simple Agent
Create weather_agent.py
:
import json
import random
from datetime import datetime
class WeatherAgent:
"""Simulated weather information agent"""
def __init__(self):
self.cities = {
'london': {'temp_base': 15, 'variance': 5},
'new_york': {'temp_base': 20, 'variance': 8},
'tokyo': {'temp_base': 18, 'variance': 6},
'sydney': {'temp_base': 22, 'variance': 4}
}
def get_weather(self, city):
"""Generate simulated weather data"""
if city.lower() not in self.cities:
return None
city_data = self.cities[city.lower()]
temp = city_data['temp_base'] + random.uniform(-city_data['variance'], city_data['variance'])
return {
'city': city,
'temperature': round(temp, 1),
'unit': 'celsius',
'conditions': random.choice(['sunny', 'cloudy', 'rainy', 'partly cloudy']),
'humidity': random.randint(30, 80),
'timestamp': datetime.now().isoformat()
}
# Global agent instance
agent = WeatherAgent()
def handle_message(message_bytes):
"""Main message handler for Caxton"""
try:
# Decode and parse message
message = json.loads(message_bytes.decode('utf-8'))
# Handle different performatives
if message['performative'] == 'query':
return handle_query(message)
elif message['performative'] == 'request':
return handle_request(message)
else:
return create_not_understood(message)
except Exception as e:
return create_failure(message, str(e))
def handle_query(message):
"""Handle weather queries"""
content = message.get('content', {})
city = content.get('city')
if not city:
return create_failure(message, "No city specified")
weather = agent.get_weather(city)
if weather:
response = {
'performative': 'inform',
'sender': message['receiver'],
'receiver': message['sender'],
'conversation_id': message.get('conversation_id'),
'in_reply_to': message.get('id'),
'content': {
'weather': weather
}
}
else:
response = {
'performative': 'failure',
'sender': message['receiver'],
'receiver': message['sender'],
'conversation_id': message.get('conversation_id'),
'in_reply_to': message.get('id'),
'content': {
'error': f"Unknown city: {city}"
}
}
return json.dumps(response).encode('utf-8')
def create_not_understood(message):
"""Create not-understood response"""
response = {
'performative': 'not-understood',
'sender': message['receiver'],
'receiver': message['sender'],
'conversation_id': message.get('conversation_id'),
'in_reply_to': message.get('id'),
'content': {
'error': f"Unknown performative: {message['performative']}"
}
}
return json.dumps(response).encode('utf-8')
def create_failure(message, error):
"""Create failure response"""
response = {
'performative': 'failure',
'sender': message.get('receiver', 'unknown'),
'receiver': message.get('sender', 'unknown'),
'conversation_id': message.get('conversation_id'),
'in_reply_to': message.get('id'),
'content': {
'error': error
}
}
return json.dumps(response).encode('utf-8')
Compile and Deploy
# Compile to WASM
pyodide build weather_agent.py -o weather_agent.wasm
# Deploy
caxton deploy weather_agent.wasm --name weather-service
# Test it
caxton message send \
--to weather-service \
--performative query \
--content '{"city": "London"}'
Go Agent
Setup
# Install TinyGo (Go to WASM compiler)
wget https://github.com/tinygo-org/tinygo/releases/download/v0.30.0/tinygo_0.30.0_amd64.deb
sudo dpkg -i tinygo_0.30.0_amd64.deb
Calculator Agent
Create calculator_agent.go
:
package main
import (
"encoding/json"
"fmt"
"math"
)
// Message represents a FIPA message
type Message struct {
Performative string `json:"performative"`
Sender string `json:"sender"`
Receiver string `json:"receiver"`
ConversationID string `json:"conversation_id"`
ReplyWith string `json:"reply_with,omitempty"`
InReplyTo string `json:"in_reply_to,omitempty"`
Content map[string]interface{} `json:"content"`
}
//export handle_message
func handle_message(messagePtr *byte, messageLen int) (*byte, int) {
// Convert pointer to byte slice
messageBytes := make([]byte, messageLen)
for i := 0; i < messageLen; i++ {
messageBytes[i] = *(*byte)(unsafe.Pointer(uintptr(unsafe.Pointer(messagePtr)) + uintptr(i)))
}
// Parse message
var msg Message
if err := json.Unmarshal(messageBytes, &msg); err != nil {
return nil, 0
}
// Handle calculation requests
if msg.Performative == "request" {
response := processCalculation(msg)
responseBytes, _ := json.Marshal(response)
return &responseBytes[0], len(responseBytes)
}
return nil, 0
}
func processCalculation(msg Message) Message {
operation, _ := msg.Content["operation"].(string)
operands, _ := msg.Content["operands"].([]interface{})
var result float64
var err error
switch operation {
case "add":
result = add(operands)
case "multiply":
result = multiply(operands)
case "power":
if len(operands) == 2 {
base, _ := operands[0].(float64)
exp, _ := operands[1].(float64)
result = math.Pow(base, exp)
}
default:
err = fmt.Errorf("unknown operation: %s", operation)
}
response := Message{
Performative: "inform",
Sender: msg.Receiver,
Receiver: msg.Sender,
ConversationID: msg.ConversationID,
InReplyTo: msg.ReplyWith,
}
if err != nil {
response.Performative = "failure"
response.Content = map[string]interface{}{
"error": err.Error(),
}
} else {
response.Content = map[string]interface{}{
"result": result,
"operation": operation,
}
}
return response
}
func add(operands []interface{}) float64 {
sum := 0.0
for _, op := range operands {
if val, ok := op.(float64); ok {
sum += val
}
}
return sum
}
func multiply(operands []interface{}) float64 {
product := 1.0
for _, op := range operands {
if val, ok := op.(float64); ok {
product *= val
}
}
return product
}
func main() {
// Required for WASM
}
Compile and Deploy
# Compile with TinyGo
tinygo build -o calculator.wasm -target wasi calculator_agent.go
# Deploy
caxton deploy calculator.wasm --name calculator
# Test
caxton message send \
--to calculator \
--performative request \
--content '{"operation": "add", "operands": [5, 3, 2]}'
Rust Agent
Setup
# Cargo.toml
[package]
name = "rust-agent"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2"
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "z"
lto = true
Smart Contract Agent
Create src/lib.rs
:
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
struct Message {
performative: String,
sender: String,
receiver: String,
conversation_id: Option<String>,
in_reply_to: Option<String>,
content: serde_json::Value,
}
#[derive(Default)]
struct ContractState {
balances: HashMap<String, u64>,
total_supply: u64,
}
static mut STATE: Option<ContractState> = None;
#[no_mangle]
pub extern "C" fn init() -> i32 {
unsafe {
STATE = Some(ContractState {
balances: HashMap::new(),
total_supply: 1_000_000,
});
}
0 // Success
}
#[no_mangle]
pub extern "C" fn handle_message(ptr: *const u8, len: usize) -> *const u8 {
let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
let message: Message = match serde_json::from_slice(bytes) {
Ok(msg) => msg,
Err(_) => return std::ptr::null(),
};
let response = match message.performative.as_str() {
"request" => handle_request(message),
"query" => handle_query(message),
_ => create_not_understood(message),
};
match response {
Some(resp) => {
let json = serde_json::to_vec(&resp).unwrap();
Box::into_raw(json.into_boxed_slice()) as *const u8
}
None => std::ptr::null(),
}
}
fn handle_request(msg: Message) -> Option<Message> {
let state = unsafe { STATE.as_mut()? };
let action = msg.content.get("action")?.as_str()?;
match action {
"transfer" => {
let from = msg.content.get("from")?.as_str()?;
let to = msg.content.get("to")?.as_str()?;
let amount = msg.content.get("amount")?.as_u64()?;
// Perform transfer
let from_balance = state.balances.entry(from.to_string()).or_insert(0);
if *from_balance < amount {
return create_failure(msg, "Insufficient balance");
}
*from_balance -= amount;
*state.balances.entry(to.to_string()).or_insert(0) += amount;
Some(Message {
performative: "inform".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"status": "success",
"from": from,
"to": to,
"amount": amount,
}),
})
}
"mint" => {
let to = msg.content.get("to")?.as_str()?;
let amount = msg.content.get("amount")?.as_u64()?;
*state.balances.entry(to.to_string()).or_insert(0) += amount;
state.total_supply += amount;
Some(Message {
performative: "inform".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"status": "success",
"minted": amount,
"to": to,
"total_supply": state.total_supply,
}),
})
}
_ => create_not_understood(msg),
}
}
fn handle_query(msg: Message) -> Option<Message> {
let state = unsafe { STATE.as_ref()? };
let query_type = msg.content.get("type")?.as_str()?;
match query_type {
"balance" => {
let account = msg.content.get("account")?.as_str()?;
let balance = state.balances.get(account).unwrap_or(&0);
Some(Message {
performative: "inform".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"account": account,
"balance": balance,
}),
})
}
"total_supply" => {
Some(Message {
performative: "inform".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"total_supply": state.total_supply,
}),
})
}
_ => create_not_understood(msg),
}
}
fn create_not_understood(msg: Message) -> Option<Message> {
Some(Message {
performative: "not-understood".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"error": "Message not understood",
}),
})
}
fn create_failure(msg: Message, error: &str) -> Option<Message> {
Some(Message {
performative: "failure".to_string(),
sender: msg.receiver,
receiver: msg.sender,
conversation_id: msg.conversation_id,
in_reply_to: Some(msg.conversation_id.unwrap_or_default()),
content: serde_json::json!({
"error": error,
}),
})
}
Compile and Deploy
# Build for WASM
cargo build --target wasm32-wasi --release
# Deploy
caxton deploy target/wasm32-wasi/release/rust_agent.wasm --name token-contract
# Test minting
caxton message send \
--to token-contract \
--performative request \
--content '{"action": "mint", "to": "alice", "amount": 1000}'
# Query balance
caxton message send \
--to token-contract \
--performative query \
--content '{"type": "balance", "account": "alice"}'
Testing Your Agent
Unit Testing
Test your agent logic before deployment:
// test-agent.js
import { handle_message } from './echo-agent.js';
function test_echo() {
const testMessage = {
performative: 'inform',
sender: 'test-sender',
receiver: 'echo-agent',
content: { text: 'Hello' }
};
const input = new TextEncoder().encode(JSON.stringify(testMessage));
const output = handle_message(input);
const response = JSON.parse(new TextDecoder().decode(output));
console.assert(response.content.text === 'Echo: Hello');
console.log('✓ Echo test passed');
}
test_echo();
Integration Testing
Test with Caxton running:
# Deploy test agent
caxton deploy my-agent.wasm --name test-agent
# Send test messages
caxton test agent test-agent --suite basic
# Custom test script
cat > test.sh << 'EOF'
#!/bin/bash
# Send message and check response
RESPONSE=$(caxton message send \
--to test-agent \
--performative request \
--content '{"test": true}' \
--wait-reply \
--timeout 5s)
echo "$RESPONSE" | jq '.content.status' | grep -q "success"
EOF
chmod +x test.sh
./test.sh
Best Practices
- Handle All Performatives: Always handle unknown performatives gracefully
- Validate Input: Check message format and content before processing
- Use Structured Logging: Log important events for debugging
- Implement Timeouts: Don’t block indefinitely on operations
- Minimize State: Keep agent state minimal and consider persistence
- Error Handling: Return appropriate FIPA error responses
- Resource Limits: Be mindful of memory and CPU usage
- Idempotency: Make operations idempotent where possible
Next Steps
- Message Protocol Guide - Deep dive into FIPA protocols
- API Reference - Complete API documentation
- Testing Guide - Comprehensive testing strategies
- Production Deployment - Deploy to production