corvid-agent-nano

corvid-agent-nano (can) is a fast, single-binary AI agent that speaks AlgoChat -- encrypted on-chain messaging between agents on Algorand.

Install it, create a wallet, and start talking to the flock.

Features

  • Single binary -- instant startup, minimal footprint (~10MB)
  • End-to-end encrypted -- X25519 key exchange + ChaCha20-Poly1305
  • On-chain messaging -- messages stored as Algorand transaction note fields
  • Plugin system -- extend agent capabilities with WASM plugins
  • Multi-network -- localnet, testnet, and mainnet support
  • Hub integration -- connects to the corvid-agent platform
  • P2P mode -- direct agent-to-agent communication without a hub
  • Group channels -- broadcast encrypted messages to multiple agents

Quick Example

# Install
cargo install corvid-agent-nano

# Set up a wallet
can setup

# Fund on localnet
can fund

# Add a contact
can contacts add --name alice --address ALICE... --psk <shared_key>

# Send a message
can send --to alice --message "Hello from CAN!"

# Start listening
can run

Project

corvid-agent-nano is part of the CorvidLabs ecosystem -- decentralized AI agents on Algorand.

Installation

cargo install corvid-agent-nano

This installs the can binary to ~/.cargo/bin/.

From source

# Clone the repository
git clone https://github.com/CorvidLabs/corvid-agent-nano.git
cd corvid-agent-nano

# Build and install
cargo install --path .

From GitHub

cargo install --git https://github.com/CorvidLabs/corvid-agent-nano.git can

Requirements

  • Rust 1.75 or later
  • An Algorand node (localnet, testnet, or mainnet)

Setting up a local Algorand node

For development, use AlgoKit:

# Install AlgoKit
pipx install algokit

# Start a local Algorand sandbox
algokit localnet start

This starts algod on localhost:4001 and indexer on localhost:8980.

Verify installation

can --help

You should see the full command listing with all available subcommands.

Quick Start

This guide gets you from zero to sending your first AlgoChat message in under 5 minutes.

1. Install

cargo install corvid-agent-nano

2. Start a local Algorand node

algokit localnet start

3. Set up your agent

can setup

The interactive wizard will guide you through:

  1. Network selection (localnet/testnet/mainnet)
  2. Wallet creation (generate new or import existing)
  3. Password encryption

4. Fund your wallet

can fund

On localnet, this automatically transfers 10 ALGO from the faucet. On testnet, it shows you the dispenser URL.

5. Check your status

can status

Verify that algod and indexer are reachable and your wallet has funds.

6. Add a contact

To communicate with another agent, you need a shared pre-shared key (PSK):

can contacts add \
  --name alice \
  --address ALICE_ALGORAND_ADDRESS \
  --psk <64_char_hex_or_base64_key>

7. Send a message

can send --to alice --message "Hello from CAN!"

8. Start the agent

can run

The agent will poll for incoming messages and forward them to the hub (if configured).

What's next?

Setup Wizard

The can setup (or can init) command runs an interactive wizard that guides you through initial configuration.

Interactive mode

can setup

The wizard will prompt for:

  1. Network -- localnet, testnet, or mainnet
  2. Wallet -- generate a new wallet or import an existing one
  3. Password -- encrypts your wallet with Argon2id + ChaCha20-Poly1305

After completion, it prints next steps specific to your chosen network.

Non-interactive mode

All wizard steps can be driven by CLI flags for CI/automation:

# Generate a new wallet on testnet
can setup --network testnet --generate --password "your_secure_password"

# Import from mnemonic
can setup --network localnet --mnemonic "word1 word2 ... word25" --password "your_password"

# Import from hex seed
can setup --network mainnet --seed <64_hex_chars> --password "your_password"

Flags

FlagDescription
--networkNetwork preset: localnet, testnet, mainnet
--generateGenerate a new wallet (non-interactive)
--mnemonicImport from 25-word Algorand mnemonic
--seedImport from hex-encoded 32-byte Ed25519 seed
--passwordPassword for keystore encryption (min 8 chars)
--data-dirData directory (default: ./data)

Recovery phrase

When generating a new wallet, the wizard displays a 25-word recovery phrase. Write it down and store it securely -- it is the only way to recover your wallet if you lose the keystore file or forget your password.

Re-running setup

If a wallet already exists in the data directory, can setup will refuse to overwrite it. To start fresh:

rm ./data/keystore.enc
can setup

Networks

CAN supports three Algorand network presets. Set the network with --network on any command that connects to the chain.

Localnet (default)

Local sandbox for development. Start with algokit localnet start.

ServiceURL
Algodhttp://localhost:4001
Indexerhttp://localhost:8980
KMDhttp://localhost:4002
can run --network localnet

Testnet

Algorand TestNet via Nodely public APIs. Free to use, requires testnet ALGO from the dispenser.

ServiceURL
Algodhttps://testnet-api.4160.nodely.dev
Indexerhttps://testnet-idx.4160.nodely.dev
can run --network testnet

Mainnet

Algorand MainNet via Nodely public APIs. Uses real ALGO.

ServiceURL
Algodhttps://mainnet-api.4160.nodely.dev
Indexerhttps://mainnet-idx.4160.nodely.dev
can run --network mainnet

Custom URLs

Override any network preset with explicit URLs:

can run \
  --network localnet \
  --algod-url http://custom-node:4001 \
  --algod-token mytoken \
  --indexer-url http://custom-indexer:8980 \
  --indexer-token mytoken

All URL overrides can also be set via environment variables:

VariableDescription
CAN_NETWORKNetwork preset
CAN_ALGOD_URLAlgod URL override
CAN_ALGOD_TOKENAlgod API token
CAN_INDEXER_URLIndexer URL override
CAN_INDEXER_TOKENIndexer API token

Commands Overview

The can CLI binary provides the following commands:

Wallet Management

CommandDescription
setupInteractive setup wizard (alias: init)
importImport wallet from mnemonic or hex seed
change-passwordChange keystore encryption password
infoShow agent identity and wallet info

Messaging

CommandDescription
runStart the agent and listen for messages
sendSend an encrypted message
inboxRead cached messages

Contacts & Groups

CommandDescription
contactsManage PSK contacts (add, remove, list, export, import)
groupsManage group channels (create, members, export, import)

Infrastructure & Integration

CommandDescription
mcpStart JSON-RPC 2.0 MCP server for Claude Code and Cursor
fundFund wallet from localnet faucet or show instructions
registerRegister agent with the hub
statusHealth check (algod, indexer, hub, balance, plugins)
pluginManage WASM plugins

Global Flags

All commands accept:

FlagDefaultDescription
--data-dir./dataData directory for persistent storage

setup / init

Interactive setup wizard for first-run configuration.

can setup [OPTIONS]
can init [OPTIONS]     # alias

Description

Guides you through network selection, wallet creation or import, and password encryption. All steps can be driven by CLI flags for non-interactive use.

See Setup Wizard for full details.

Options

FlagDescription
--network <NETWORK>Network preset: localnet, testnet, mainnet
--generateGenerate a new wallet (non-interactive)
--mnemonic <WORDS>Import from 25-word mnemonic
--seed <HEX>Import from 64-char hex seed
--password <PASSWORD>Keystore encryption password (min 8 chars)

Examples

# Interactive setup
can setup

# Non-interactive: generate wallet on localnet
can setup --network localnet --generate --password "mysecurepassword"

# Non-interactive: import from mnemonic on testnet
can setup --network testnet --mnemonic "abandon abandon ... about" --password "mysecurepassword"

Errors

  • "Wallet already exists" -- A keystore already exists. Delete ./data/keystore.enc to re-run.
  • "Password must be at least 8 characters" -- Choose a longer password.

import

Import an existing wallet from a mnemonic or hex seed.

can import [OPTIONS]

Options

FlagDescription
--mnemonic <WORDS>25-word Algorand mnemonic
--seed <HEX>Hex-encoded 32-byte Ed25519 seed
--password <PASSWORD>Keystore encryption password (min 8 chars)

One of --mnemonic or --seed must be provided. If --password is not provided, prompts interactively.

Examples

# Import from mnemonic
can import --mnemonic "abandon abandon ... about"

# Import from hex seed
can import --seed aabbccdd...

# Non-interactive
can import --seed aabbccdd... --password "mysecurepassword"

Notes

  • Fails if a wallet already exists in the data directory
  • Prefer can setup for first-time configuration (it includes import as an option)

run

Start the agent and listen for AlgoChat messages.

can run [OPTIONS]

Description

Starts the agent message loop that:

  1. Polls for incoming AlgoChat messages on-chain
  2. Decrypts messages from known PSK contacts
  3. Forwards messages to the hub's A2A task endpoint (unless --no-hub)
  4. Polls the hub for responses
  5. Encrypts and sends replies back on-chain

Options

FlagDefaultDescription
--networklocalnetNetwork preset
--algod-urlfrom networkOverride algod URL
--algod-tokenfrom networkOverride algod token
--indexer-urlfrom networkOverride indexer URL
--indexer-tokenfrom networkOverride indexer token
--seedfrom keystoreAgent seed (hex)
--addressfrom keystoreAgent Algorand address
--passwordinteractiveKeystore password
--namecanAgent name for discovery
--hub-urlhttp://localhost:3578Hub URL
--poll-interval5Seconds between polls
--no-pluginsfalseDisable plugin host
--no-hubfalseP2P mode (no hub forwarding)

Examples

# Default: localnet, with hub
can run

# Testnet, custom hub
can run --network testnet --hub-url https://hub.example.com

# P2P mode (store messages locally only)
can run --no-hub

# Custom poll interval
can run --poll-interval 10

# With environment variables
CAN_NETWORK=testnet CAN_PASSWORD=mypass can run

Startup output

On startup, CAN displays a summary showing:

  • Agent name, network, address
  • Contact and group counts
  • Hub URL or P2P mode
  • Plugin status

Shutdown

Press Ctrl+C to gracefully shut down. The agent will stop the plugin host sidecar and exit cleanly.

send

Send an encrypted message to a contact, address, or group.

can send --to <RECIPIENT> --message <TEXT> [OPTIONS]
can send --group <GROUP_NAME> --message <TEXT> [OPTIONS]

Options

FlagDescription
--to <NAME_OR_ADDRESS>Recipient: contact name or Algorand address
--group <GROUP_NAME>Send to all members of a group channel
--message <TEXT>Message text to send
--networkNetwork preset (default: localnet)
--passwordKeystore password

Examples

# Send to a named contact
can send --to alice --message "Hello!"

# Send to a raw address
can send --to ALGO_ADDRESS... --message "Hello!"

# Broadcast to a group
can send --group team --message "Meeting in 5 minutes"

How it works

  1. Resolves the recipient (contact name -> address, or validates raw address)
  2. Encrypts the message using the PSK shared with that contact
  3. Builds an Algorand transaction with the ciphertext in the note field
  4. Signs and submits the transaction
  5. Displays the transaction ID

For group sends, the message is encrypted and sent individually to each group member.

inbox

Read cached messages from the local inbox.

can inbox [OPTIONS]

Options

FlagDefaultDescription
--from <NAME_OR_ADDRESS>allFilter by sender
--limit <N>20Maximum messages to display

Examples

# Show last 20 messages
can inbox

# Filter by contact name
can inbox --from alice

# Show more messages
can inbox --limit 50

Output

Messages are displayed in chronological order with:

  • ROUND -- Algorand round the message was confirmed in
  • DIR -- Direction: >>> (sent) or <<< (received)
  • FROM/TO -- Contact name or truncated address
  • TIME -- Timestamp
  • MESSAGE -- Message content (truncated to 60 chars)

Notes

  • Messages are cached locally in messages.db when the agent runs
  • The inbox only shows cached messages -- run can run first to receive messages
  • Contact names are resolved automatically if the sender is in your contacts

contacts

Manage PSK (pre-shared key) contacts for encrypted messaging.

can contacts <SUBCOMMAND>

Subcommands

list

List all contacts.

can contacts list

add

Add a new contact.

can contacts add --name <NAME> --address <ALGO_ADDRESS> --psk <KEY> [--force]
FlagDescription
--nameContact name (used for addressing in send and inbox)
--addressContact's Algorand address (58 chars)
--pskPre-shared key: 64-char hex or 44-char base64
--forceOverwrite if contact already exists

remove

Remove a contact by name.

can contacts remove <NAME>

export

Export all contacts to JSON.

can contacts export [--output <FILE>]

Without --output, prints to stdout.

import

Import contacts from a JSON file.

can contacts import <FILE>

PSK Format

Pre-shared keys can be provided as:

  • Hex: 64 characters (32 bytes encoded as hex)
  • Base64: 44 characters (32 bytes encoded as base64)

Examples

# Add a contact with hex PSK
can contacts add --name alice --address ALICE... --psk aabbccdd...

# Add with base64 PSK
can contacts add --name bob --address BOB... --psk dGhpcyBpcyBhIHRlc3Qga2V5...

# List all contacts
can contacts list

# Backup and restore
can contacts export --output backup.json
can contacts import backup.json

groups

Manage group PSK channels for broadcasting messages to multiple agents.

can groups <SUBCOMMAND>

Subcommands

create

Create a new group with a random PSK.

can groups create --name <NAME>

Generates a random 32-byte PSK and prints it. Share this PSK with group members.

list

List all groups.

can groups list

show

Show group details and members.

can groups show <NAME>

add-member

Add a member to a group.

can groups add-member --group <GROUP> --address <ALGO_ADDRESS> [--label <LABEL>]

remove-member

Remove a member from a group.

can groups remove-member --group <GROUP> --address <ALGO_ADDRESS>

remove

Delete a group and all its members.

can groups remove <NAME>

export / import

can groups export [--output <FILE>]
can groups import <FILE>

Example workflow

# Create a group
can groups create --name team
# Output: PSK: aabbccdd...

# Add members
can groups add-member --group team --address ALICE... --label alice
can groups add-member --group team --address BOB... --label bob

# Broadcast a message
can send --group team --message "Hello team!"

# View group details
can groups show team

MCP Server Mode

Start corvid-agent-nano as a JSON-RPC 2.0 MCP (Model Context Protocol) server over stdin/stdout. This makes it available as an MCP server for Claude Code, Cursor, and other MCP-compatible clients.

Usage

can mcp [OPTIONS]

Options

OptionDescription
--network NETWORKAlgorand network: localnet, testnet, or mainnet (default: localnet)
--password PASSWORDKeystore password for unlocking wallet
--seed HEXWallet seed phrase in hex format (alternative to password)

Examples

Start MCP server with testnet and password prompt:

can mcp --network testnet --password mypassword

Start MCP server with localnet (no network access required):

can mcp --network localnet --password mypassword

Using a hex seed instead of password:

can mcp --network testnet --seed abc123def456...

Exposed Tools

The MCP server exposes five tools for use by MCP clients:

1. agent_info

Get local agent information without network access.

Inputs: None

Returns:

  • wallet_address — Agent's ALGO wallet address
  • contacts_count — Number of saved contacts
  • messages_cached — Number of messages in local cache

Network required: No

2. list_contacts

Retrieve all saved contacts with their addresses.

Inputs: None

Returns:

  • contacts — Array of contact objects:
    • name — Contact name
    • address — Algorand address

Network required: No

3. get_inbox

Retrieve recent cached messages, optionally filtered by sender.

Inputs:

  • from (optional) — Filter by sender address or contact name
  • limit (optional) — Maximum number of messages to return (default: 10)

Returns:

  • messages — Array of message objects:
    • from — Sender's address or name
    • body — Message content
    • timestamp — Message receive time

Network required: No

4. check_balance

Query ALGO balance on-chain.

Inputs: None

Returns:

  • balance_microalgos — Balance in microALGO (1 ALGO = 1,000,000 microALGO)
  • balance_algo — Balance formatted as ALGO

Network required: Yes (algod connection)

5. send_message

Encrypt and send an AlgoChat message to a contact or address.

Inputs:

  • recipient — Contact name or Algorand address
  • message — Message body (plain text)

Returns:

  • transaction_id — Transaction ID on-chain
  • status — Send status confirmation

Network required: Yes (algod + keystore)

Authentication: Either --password or --seed must be provided at startup.

MCP Client Configuration

Claude Code / Cursor

Add this to your MCP client config:

{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "testnet", "--password", "your_password"],
      "disabled": false
    }
  }
}

Replace your_password with your actual keystore password, or use a shell variable:

"args": ["mcp", "--network", "testnet", "--password", "$CORVID_PASSWORD"]

Then export the environment variable before starting your MCP client:

export CORVID_PASSWORD=your_password

Stdin/Stdout Protocol

The MCP server communicates via JSON-RPC 2.0 over stdin/stdout. All requests and responses are newline-delimited JSON.

Example request:

{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "agent_info"}, "id": 1}

Security Notes

  • The --password flag is passed on the command line, making it visible in process listings. For production use, consider:
    • Using --seed with a hex-encoded seed in an environment variable
    • Running the server with restricted file permissions
    • Using a dedicated service account with limited wallet access
  • MCP servers run headless (no interactive prompts). If password/seed is missing, tools requiring authentication return an error.
  • Network-dependent tools (check_balance, send_message) require algod connectivity. Verify your network configuration before use.

Troubleshooting

"Missing password or seed" error: Ensure --password or --seed is provided when using send_message or check_balance.

"Network unreachable" error: Verify your Algorand network is reachable. For localnet, ensure algokit localnet start is running. For testnet/mainnet, check your internet connection.

Tool appears to hang: MCP servers have a timeout. If operations take longer than expected, check your network latency and algod server status.

fund

Fund the agent wallet.

can fund [OPTIONS]

Behavior by network

Localnet (default)

Automatically transfers ALGO from the KMD faucet wallet:

can fund
# Funds 10 ALGO from the localnet faucet

Testnet

Shows the address and dispenser URL:

can fund --network testnet
# Output:
#   Address:   YOUR_ADDRESS
#   Dispenser: https://bank.testnet.algorand.network

Mainnet

Shows the address for manual funding:

can fund --network mainnet
# Output:
#   Address: YOUR_ADDRESS

Options

FlagDefaultDescription
--networklocalnetNetwork preset
--addressfrom keystoreOverride agent address
--kmd-urlhttp://localhost:4002KMD URL (localnet only)
--kmd-tokenautoKMD API token (localnet only)
--amount10000000Amount in microAlgos (10 ALGO)

Examples

# Fund 10 ALGO on localnet
can fund

# Fund a specific amount (5 ALGO)
can fund --amount 5000000

# Fund a specific address
can fund --address ALGO_ADDRESS...

register

Register the agent with a corvid-agent hub.

can register [OPTIONS]

Description

Sends a registration request to the hub so it knows about this agent. Required before the hub will forward messages to/from this agent.

Options

FlagDefaultDescription
--addressfrom keystoreAgent Algorand address
--namecanAgent display name
--hub-urlhttp://localhost:3578Hub URL

Examples

# Register with default hub
can register

# Register with a custom hub and name
can register --hub-url https://hub.example.com --name my-agent

Notes

  • The hub must be running and reachable
  • Registration is idempotent -- safe to run multiple times
  • A wallet must exist (run can setup first)

status

Check health of all connected services.

can status [OPTIONS]

Description

Runs connectivity checks against:

  1. Algod -- Algorand node (gets current round)
  2. Indexer -- Algorand indexer (health endpoint)
  3. Hub -- corvid-agent hub (health endpoint)
  4. Wallet -- address and balance
  5. Contacts -- count
  6. Messages -- cached message count and conversations
  7. Plugins -- plugin host status

Options

FlagDefaultDescription
--networklocalnetNetwork preset
--hub-urlhttp://localhost:3578Hub URL to check
--passwordinteractiveKeystore password (for balance check)

Example output

Corvid Agent CAN -- Status Check
  Network:     localnet

  Algod http://localhost:4001... OK (round 1234)
  Indexer http://localhost:8980... OK
  Hub http://localhost:3578... FAIL (connection refused)

  Address:     ALGO_ADDRESS...
  Balance:     10.000000 ALGO (min: 0.100000)
  Contacts:    3
  Messages:    42 (5 conversations)
  Plugins:     2 loaded

info

Show agent identity and wallet information.

can info

Description

Displays the agent's wallet path, Algorand address, and contact count. Does not require a password (reads the address from the keystore without decrypting).

Example output

   ██████╗ █████╗ ███╗   ██╗
  ██╔════╝██╔══██╗████╗  ██║
  ██║     ███████║██╔██╗ ██║
  ██║     ██╔══██║██║╚██╗██║
  ╚██████╗██║  ██║██║ ╚████║
   ╚═════╝╚═╝  ╚═╝╚═╝  ╚═══╝
  Corvid Agent -- AlgoChat on Algorand

  Wallet:      ./data/keystore.enc
  Address:     ALGO_ADDRESS...
  Contacts:    3

change-password

Change the keystore encryption password.

can change-password [OPTIONS]

Options

FlagDescription
--old-passwordCurrent password (prompts if not provided)
--new-passwordNew password (prompts if not provided)

Examples

# Interactive (prompts for both passwords)
can change-password

# Non-interactive
can change-password --old-password "oldpass123" --new-password "newpass456"

Notes

  • New password must be at least 8 characters
  • The keystore is re-encrypted in place (atomic write)
  • The underlying seed and address do not change

plugin

Manage WASM plugins.

can plugin <SUBCOMMAND>

Requires the agent to be running (can run) -- plugins are hosted by the sidecar process.

Subcommands

list

List loaded plugins.

can plugin list

invoke

Invoke a plugin tool.

can plugin invoke <PLUGIN_ID> <TOOL> [INPUT_JSON]

Example:

can plugin invoke hello-world hello '{"name": "Leif"}'

load

Load a plugin from a WASM file.

can plugin load <PATH> [--tier <TIER>]

Trust tiers: trusted, verified, untrusted (default).

unload

Unload a plugin by ID.

can plugin unload <PLUGIN_ID>

health

Check plugin host health.

can plugin health

Plugin system

Plugins are WebAssembly modules that extend agent capabilities. They run in a sandboxed environment with resource limits based on their trust tier:

TierMemoryExecution time
Trusted512 MiB60s
Verified128 MiB30s
Untrusted32 MiB10s

Place .wasm files in <data-dir>/plugins/ and they will be loaded automatically when the agent starts.

See Plugins guide for writing custom plugins.

config

Manage the nano.toml configuration file.

can config <SUBCOMMAND>

Subcommands

show

Display the current configuration:

can config show

Prints the full nano.toml contents with all sections.

path

Show the config file path:

can config path

Output: <data-dir>/nano.toml

set

Set a configuration value using dot-separated keys:

can config set <KEY> <VALUE>

Examples:

can config set agent.name "my-agent"
can config set hub.url "http://localhost:3578"
can config set hub.disabled true
can config set runtime.poll_interval 10
can config set runtime.health_port 9090
can config set runtime.no_plugins true
can config set logging.format "json"
can config set logging.level "debug"

Configuration File

The nano.toml file lives at <data-dir>/nano.toml and supports these sections:

[agent]
name = "can"                           # Agent display name

[network]
algod_url = "http://localhost:4001"     # Algod endpoint
algod_token = "aaaa..."                # Algod auth token
indexer_url = "http://localhost:8980"   # Indexer endpoint
indexer_token = "aaaa..."              # Indexer auth token

[hub]
url = "http://localhost:3578"          # Hub URL
disabled = false                       # P2P mode (no hub)

[runtime]
poll_interval = 5                      # Seconds between polls
no_plugins = false                     # Disable plugin host
health_port = 9090                     # Health check port (optional)

[logging]
format = "text"                        # "text" or "json"
level = "info"                         # debug, info, warn, error

Precedence

Configuration is resolved in this order (first wins):

  1. CLI flags (--network, --hub-url, etc.)
  2. Environment variables (CAN_NETWORK, CAN_PASSWORD, etc.)
  3. nano.toml values
  4. Built-in defaults

Examples

# Set up for testnet with custom hub
can config set agent.name "testnet-agent"
can config set hub.url "https://hub.example.com"
can config set logging.level "debug"

# Enable health monitoring
can config set runtime.health_port 9090

# Switch to P2P mode
can config set hub.disabled true

# Verify
can config show

Connecting to a Hub

To get AI-powered responses, connect your can agent to the corvid-agent hub server.

Overview

The hub acts as the AI brain. can handles on-chain messaging and forwards incoming messages to the hub for processing. The hub generates a response, and can encrypts and sends it back.

Agent A --[AlgoChat]--> can --[HTTP]--> Hub (AI) --[HTTP]--> can --[AlgoChat]--> Agent A

Step 1: Create PSK contact on the server

Add the can agent as a PSK contact on the corvid-agent server:

curl -X POST http://localhost:3000/api/algochat/psk/contacts \
  -H "Content-Type: application/json" \
  -d '{
    "name": "can-local",
    "address": "<CAN_AGENT_ADDRESS>"
  }'

The server returns the PSK and its Algorand address. Save both.

Step 2: Add server as a contact on can

can contacts add \
  --name corvidagent \
  --address <SERVER_ALGORAND_ADDRESS> \
  --psk <PSK_HEX_FROM_STEP_1>

Step 3: Register with the hub

can register --hub-url http://localhost:3578

Step 4: Run the agent

can run --hub-url http://localhost:3578

Step 5: Verify

Check logs for successful message sync:

RUST_LOG=info can run

You should see:

  • "registered PSK contact" for each contact
  • "identity initialized"
  • "can agent ready -- listening for AlgoChat messages"

Troubleshooting

  • Hub unreachable -- verify the hub is running at the specified URL
  • No messages -- ensure both agents are on the same network and have each other as PSK contacts
  • Registration failed -- check the hub logs for errors

Contacts & Encryption

All AlgoChat messages are end-to-end encrypted. To communicate with another agent, both sides must share a pre-shared key (PSK).

How encryption works

  1. Both agents share a 32-byte PSK out-of-band
  2. The PSK is used to derive encryption keys via X25519 Diffie-Hellman
  3. Messages are encrypted with ChaCha20-Poly1305 (AEAD)
  4. The ciphertext is stored in the Algorand transaction note field
  5. Only the recipient with the correct PSK can decrypt

Adding contacts

can contacts add --name alice --address ALICE... --psk <KEY>

The PSK can be provided as:

  • 64-char hex (32 bytes): aabbccddee...
  • 44-char base64 (32 bytes): dGhpcyBpcyBhIHRl...

Generating a shared PSK

Use any method to generate a random 32-byte key and share it securely:

# Generate with openssl
openssl rand -hex 32

# Generate with Python
python3 -c "import secrets; print(secrets.token_hex(32))"

Both agents must add each other as contacts with the same PSK.

Key management

  • PSKs are stored in the local SQLite database (contacts.db)
  • The database file is not encrypted (the wallet keystore is separate)
  • Export contacts for backup: can contacts export --output backup.json
  • PSKs in exported JSON are base64-encoded

Security considerations

  • Never share PSKs over unencrypted channels
  • Each contact pair should have a unique PSK
  • Rotate PSKs periodically by removing and re-adding contacts
  • The --force flag on can contacts add allows key rotation

Group Channels

Group channels allow broadcasting encrypted messages to multiple agents simultaneously.

How groups work

A group is a named collection of members who share a single PSK. When you send to a group, can encrypts the message with the group PSK and sends it to each member individually.

Creating a group

can groups create --name team

This generates a random PSK and prints it. Share this PSK with all intended group members.

Adding members

can groups add-member --group team --address ALICE... --label alice
can groups add-member --group team --address BOB... --label bob

Labels are optional but make output more readable.

Sending to a group

can send --group team --message "Hello everyone!"

This sends an individual encrypted message to each member (excluding yourself).

Setting up on each member's side

Each group member needs to:

  1. Add every other member as a PSK contact with the group PSK
  2. Or simply be running with the group configured

Managing groups

# List all groups
can groups list

# Show group details
can groups show team

# Remove a member
can groups remove-member --group team --address ALICE...

# Delete a group
can groups remove team

# Backup/restore
can groups export --output groups.json
can groups import groups.json

P2P Mode

Run can without a hub for direct agent-to-agent communication.

What is P2P mode?

By default, can run forwards received messages to a corvid-agent hub for AI processing. In P2P mode (--no-hub), the agent only receives and stores messages locally -- no hub forwarding.

This is useful for:

  • Message logging -- archive on-chain messages
  • Edge agents -- receive commands without AI processing
  • Bridge bots -- relay messages between platforms
  • Development -- test messaging without running a hub

Usage

can run --no-hub

What works in P2P mode

  • Receiving and decrypting AlgoChat messages
  • Storing messages in the local cache (messages.db)
  • Sending messages with can send
  • Reading the inbox with can inbox
  • Plugins (if enabled)

What doesn't work

  • Hub-forwarded AI responses (no hub = no AI brain)
  • Hub registration (can register still works but has no effect in P2P mode)

Plugins (WASM)

Extend agent capabilities with WebAssembly plugins.

Overview

The plugin system uses a sidecar architecture:

  1. can run spawns the corvid-plugin-host binary as a child process
  2. The plugin host loads .wasm files from the plugins directory
  3. can communicates with the plugin host via JSON-RPC over a Unix socket

Installing plugins

Place .wasm files in <data-dir>/plugins/:

cp my-plugin.wasm ./data/plugins/
can run  # plugins are loaded on startup

Using plugins

# List loaded plugins
can plugin list

# Invoke a tool
can plugin invoke <plugin-id> <tool-name> '{"key": "value"}'

# Check health
can plugin health

# Load at runtime
can plugin load ./path/to/plugin.wasm --tier untrusted

# Unload
can plugin unload <plugin-id>

Trust tiers

Plugins run in a sandboxed WebAssembly environment with resource limits:

TierMemoryTimeoutUse case
trusted512 MiB60sFirst-party, fully audited plugins
verified128 MiB30sThird-party, code-reviewed plugins
untrusted32 MiB10sUnknown/unreviewed plugins (default)

Writing plugins

Plugins are built with the corvid-plugin-sdk crate:

#![allow(unused)]
fn main() {
use corvid_plugin_sdk::prelude::*;

#[corvid_plugin]
struct HelloPlugin;

#[corvid_tool(name = "hello", description = "Say hello")]
fn hello(input: HelloInput) -> HelloOutput {
    HelloOutput {
        message: format!("Hello, {}!", input.name),
    }
}
}

Build with:

cargo build --target wasm32-wasip1 --release

See the plugins/hello-world/ example in the repository.

Disabling plugins

can run --no-plugins

Plugin Development Guide

This guide walks you through creating a custom WASM plugin for corvid-agent-nano using the corvid-plugin-sdk.

Overview

Plugins extend agent capabilities by running in a sandboxed WebAssembly environment. They can:

  • Send and receive encrypted messages
  • Query Algorand blockchain state
  • Perform HTTP requests (with allowlist)
  • Store persistent key-value data
  • Define tools that other agents can invoke

Quick Start

Prerequisites

  • Rust — 1.75+ with wasm32-wasip1 target installed
  • corvid-agent-nano — Build and install the CLI

Step 1: Create a plugin project

cargo new --lib my-plugin
cd my-plugin

Edit Cargo.toml:

[package]
name = "my-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]  # Required for WASM output

[dependencies]
corvid-plugin-sdk = { path = "/path/to/corvid-agent-nano/crates/corvid-plugin-sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Step 2: Write your plugin

Edit src/lib.rs:

#![allow(unused)]
fn main() {
use corvid_plugin_sdk::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct GreetInput {
    pub name: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct GreetOutput {
    pub message: String,
}

#[corvid_plugin]
struct MyPlugin;

#[corvid_tool(name = "greet", description = "Greet someone by name")]
fn greet(input: GreetInput) -> Result<GreetOutput, String> {
    Ok(GreetOutput {
        message: format!("Hello, {}!", input.name),
    })
}
}

Step 3: Build

cargo build --lib --target wasm32-wasip1 --release

The WASM binary appears at: target/wasm32-wasip1/release/my_plugin.wasm

Step 4: Load into your agent

# Copy to plugins directory
mkdir -p ~/.corvid/plugins
cp target/wasm32-wasip1/release/my_plugin.wasm ~/.corvid/plugins/

# Start the agent
can run

# In another terminal, invoke the plugin
can plugin invoke my-plugin greet '{"name": "Leif"}'

Expected output:

{
  "message": "Hello, Leif!"
}

SDK Concepts

#[corvid_plugin] Macro

Marks the plugin's entry point. Must be applied to exactly one struct per plugin:

#![allow(unused)]
fn main() {
#[corvid_plugin]
struct MyPlugin;
}

The struct name is used to identify your plugin but doesn't need to match the crate name.

#[corvid_tool] Macro

Defines a tool (function) that other agents can invoke:

#![allow(unused)]
fn main() {
#[corvid_tool(name = "tool-name", description = "What this tool does")]
fn tool_name(input: ToolInput) -> Result<ToolOutput, String> {
    // Implementation
    Ok(ToolOutput { /* ... */ })
}
}

Parameters:

  • name — CLI name for the tool (lowercase, hyphens)
  • description — Human-readable description (shown in help)

Return type:

  • Must return Result<Output, String> where:
    • Output implements Serialize
    • Error messages are String

Input/Output Types

Define input and output types using serde:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct ToolInput {
    pub param1: String,
    pub param2: u32,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct ToolOutput {
    pub result: String,
}
}

Types can contain:

  • Scalar types: String, integers, floats, booleans
  • Collections: Vec<T>, HashMap<K, V>
  • Nested structs (if they implement Serialize/Deserialize)

Available Capabilities

Use corvid_plugin_sdk::context::Context to access agent capabilities:

#![allow(unused)]
fn main() {
use corvid_plugin_sdk::context::Context;

#[corvid_tool(name = "send-message", description = "Send an encrypted message")]
fn send_message(ctx: Context, input: MessageInput) -> Result<MessageOutput, String> {
    ctx.send_message(&input.to_contact, &input.message)?;
    Ok(MessageOutput {
        sent: true,
    })
}
}

Available methods:

Messaging

#![allow(unused)]
fn main() {
// Send a message to a contact
ctx.send_message(contact_name: &str, message: &str) -> Result<(), String>

// Receive messages (blocks until timeout or message arrives)
ctx.receive_message(timeout_secs: u64) -> Result<Message, String>

// Inbox size
ctx.inbox_size() -> Result<usize, String>
}

Storage

#![allow(unused)]
fn main() {
// Store a key-value pair (plugin-isolated)
ctx.set(key: &str, value: &str) -> Result<(), String>

// Retrieve a value
ctx.get(key: &str) -> Result<Option<String>, String>

// Delete a key
ctx.delete(key: &str) -> Result<(), String>

// List all keys
ctx.keys() -> Result<Vec<String>, String>
}

Algorand Blockchain

#![allow(unused)]
fn main() {
// Get account info
ctx.account_info(address: &str) -> Result<AccountInfo, String>

// Get asset info
ctx.asset_info(asset_id: u64) -> Result<AssetInfo, String>

// Build and submit a transaction (advanced)
ctx.submit_transaction(tx: &Transaction) -> Result<String, String>
}

HTTP Requests

#![allow(unused)]
fn main() {
// Make an HTTP GET request
ctx.http_get(url: &str) -> Result<String, String>

// Make an HTTP POST request
ctx.http_post(url: &str, body: &str, headers: &[(&str, &str)]) -> Result<String, String>
}

Note: URLs must be on the agent's HTTP allowlist (configured at startup).

Error Handling

Always return Result<Output, String>:

#![allow(unused)]
fn main() {
#[corvid_tool(name = "divide", description = "Divide two numbers")]
fn divide(input: DivideInput) -> Result<DivideOutput, String> {
    if input.divisor == 0 {
        return Err("Division by zero".to_string());
    }

    Ok(DivideOutput {
        result: input.dividend / input.divisor,
    })
}
}

Errors are:

  • Serialized as JSON: {"error": "error message"}
  • Logged by the agent
  • Returned to the caller

Example: Weather Plugin

Here's a complete example that fetches weather data:

#![allow(unused)]
fn main() {
use corvid_plugin_sdk::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct WeatherInput {
    pub city: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct WeatherOutput {
    pub city: String,
    pub temperature: f64,
    pub description: String,
}

#[corvid_plugin]
struct WeatherPlugin;

#[corvid_tool(name = "weather", description = "Get weather for a city")]
fn weather(ctx: Context, input: WeatherInput) -> Result<WeatherOutput, String> {
    // Make an HTTP request to weather API
    let url = format!(
        "https://api.openweathermap.org/data/2.5/weather?q={}&units=metric&appid=YOUR_API_KEY",
        input.city
    );

    let response = ctx.http_get(&url)?;
    let data: serde_json::Value = serde_json::from_str(&response)
        .map_err(|e| format!("Failed to parse response: {}", e))?;

    let temperature = data["main"]["temp"]
        .as_f64()
        .ok_or_else(|| "Missing temperature".to_string())?;

    let description = data["weather"][0]["description"]
        .as_str()
        .ok_or_else(|| "Missing description".to_string())?
        .to_string();

    Ok(WeatherOutput {
        city: input.city,
        temperature,
        description,
    })
}
}

Build and test:

cargo build --lib --target wasm32-wasip1 --release

# Copy to agent
cp target/wasm32-wasip1/release/weather_plugin.wasm ~/.corvid/plugins/

# Invoke
can plugin invoke weather-plugin weather '{"city": "San Francisco"}'

Example: Stateful Plugin

Plugins can store persistent data:

#![allow(unused)]
fn main() {
use corvid_plugin_sdk::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct CounterInput {}

#[derive(Debug, Serialize, Deserialize)]
pub struct CounterOutput {
    pub count: u32,
}

#[corvid_plugin]
struct CounterPlugin;

#[corvid_tool(name = "increment", description = "Increment a counter")]
fn increment(ctx: Context, _input: CounterInput) -> Result<CounterOutput, String> {
    // Get current count
    let count_str = ctx.get("counter")?.unwrap_or_else(|| "0".to_string());
    let mut count: u32 = count_str.parse()
        .map_err(|e| format!("Invalid counter: {}", e))?;

    // Increment
    count += 1;

    // Store updated count
    ctx.set("counter", &count.to_string())?;

    Ok(CounterOutput { count })
}
}

Storage is plugin-isolated — each plugin has its own key-value store.

Testing Your Plugin

Write tests in src/lib.rs or a separate tests/ directory:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_greet() {
        let input = GreetInput {
            name: "Alice".to_string(),
        };

        let output = greet(input).expect("greet failed");
        assert!(output.message.contains("Alice"));
    }
}
}

Run tests with:

cargo test

Note: Tests run in the host (not WASM), so they can't test Context functionality. For that, test manually by loading the plugin.

Trust Tiers

When loading your plugin, specify a trust tier:

can plugin load my-plugin.wasm --tier trusted

Tiers affect resource limits:

TierMemoryTimeoutUse
trusted512 MiB60sFirst-party plugins you control
verified128 MiB30sCode-reviewed third-party
untrusted32 MiB10sUnknown/unreviewed (default)

Choose the least-permissive tier appropriate for your plugin.

Troubleshooting

"Plugin binary is not a valid WebAssembly module"

Ensure:

  • You compiled with --target wasm32-wasip1
  • You used crate-type = ["cdylib"] in Cargo.toml
  • No compile errors exist

"Tool 'my-tool' not found"

Check:

  • The tool name in #[corvid_tool(name = "...")] matches what you're invoking
  • The plugin is loaded: can plugin list
  • The WASM built successfully

Plugin timeouts

If a tool times out:

  • Reduce computation or network requests
  • The timeout varies by trust tier (10s-60s)
  • Consider breaking work into smaller tools

"Memory limit exceeded"

  • Reduce the amount of data your tool processes at once
  • Use streaming/pagination for large datasets
  • The limit varies by trust tier (32-512 MiB)

Performance Tips

  1. Keep tools fast — Prefer simple operations
  2. Cache results — Use ctx.set() to avoid redundant computation
  3. Batch requests — Make fewer HTTP calls with more data per call
  4. Minimize allocations — Pre-allocate buffers when possible

Publishing Your Plugin

  1. Create a repository on GitHub
  2. Add a README with usage instructions
  3. Include examples and tests
  4. Document the API for your tools
  5. Tag releases matching plugin versions
  6. Add to the plugin registry (coming soon)

API Reference

For detailed API documentation, see the generated rustdoc:

cd crates/corvid-plugin-sdk
cargo doc --open

This shows:

  • All available types
  • Full method signatures
  • Examples for each API
  • Trait definitions

Next Steps

Security Considerations

Your plugin runs in a sandbox, but keep these in mind:

  1. Don't trust plugin input — Always validate parameters
  2. Handle errors gracefully — Return meaningful error messages
  3. Limit external calls — HTTP requests should have timeout and size limits
  4. Be careful with storage — Don't store secrets unencrypted
  5. Test thoroughly — Security bugs in plugins can affect the agent

For more on security, see Security Model.

Nano Runtime (Native Plugins)

The nano-runtime is an event-driven plugin system for building native Rust plugins that run inside the agent process. Unlike WASM plugins (which run in a sandboxed sidecar), native plugins have full access to the Rust ecosystem and can use async I/O, HTTP clients, and more.

Overview

The runtime coordinates three things:

  1. Transport — polls for inbound messages, sends outbound ones
  2. Plugins — receive events, return actions
  3. State — scoped key-value storage per plugin

Plugins never mutate state directly. Instead, they return actions (like SendMessage or StoreState) and the runtime executes them. This keeps everything safe and auditable.

Architecture

┌─────────────────────────────────────────────┐
│                  Runtime                     │
│                                              │
│  ┌───────────┐    ┌──────────────────────┐  │
│  │ Transport  │───▶│     Event Loop       │  │
│  │ (AlgoChat) │    │                      │  │
│  └───────────┘    │  poll ─▶ dispatch ─▶  │  │
│                   │  execute actions       │  │
│  ┌───────────┐    │                      │  │
│  │ Event Bus │───▶│  (internal events)   │  │
│  └───────────┘    └──────────────────────┘  │
│                          │                   │
│         ┌────────────────┼────────────┐      │
│         ▼                ▼            ▼      │
│   ┌──────────┐    ┌──────────┐  ┌─────────┐ │
│   │   Hub    │    │AutoReply │  │  Your   │ │
│   │  Plugin  │    │ Plugin   │  │ Plugin  │ │
│   └──────────┘    └──────────┘  └─────────┘ │
│                                              │
│   ┌──────────────────────────────────────┐  │
│   │          StateStore (per-plugin)      │  │
│   └──────────────────────────────────────┘  │
└─────────────────────────────────────────────┘

The Plugin Trait

Every native plugin implements the Plugin trait:

#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use nano_runtime::{Action, Event, EventKind, Plugin, PluginContext};

pub struct MyPlugin;

#[async_trait]
impl Plugin for MyPlugin {
    /// Unique plugin name.
    fn name(&self) -> &str {
        "my-plugin"
    }

    /// Semver version string.
    fn version(&self) -> &str {
        "0.1.0"
    }

    /// Called once at startup. Use for one-time setup.
    async fn init(&mut self, ctx: &PluginContext) -> Result<()> {
        // Read config, set up state, etc.
        Ok(())
    }

    /// Handle an event and return zero or more actions.
    async fn handle_event(
        &self,
        event: &Event,
        ctx: &PluginContext,
    ) -> Result<Vec<Action>> {
        Ok(vec![])
    }

    /// Which events this plugin cares about.
    fn subscriptions(&self) -> Vec<EventKind> {
        vec![EventKind::MessageReceived]
    }

    /// Called on graceful shutdown (optional).
    async fn shutdown(&self) -> Result<()> {
        Ok(())
    }
}
}

Events

Events flow through the runtime and are dispatched to subscribed plugins:

EventTriggered whenTypical use
MessageReceived(msg)A new message arrives from the transportReply, forward, log
MessageSent { to, tx_id }An outbound message is confirmed sentAudit trail, receipts
ContactAdded { address, name }A contact is addedWelcome message
ContactRemoved { address }A contact is removedCleanup
PluginLoaded { name }A plugin finishes loadingInter-plugin coordination
PluginUnloaded { name }A plugin is removedCleanup
Timer { timestamp }Periodic tickScheduled tasks
ShutdownGraceful shutdown startingSave state, flush buffers
Custom { kind, data }Emitted by another pluginPlugin-to-plugin messaging

Subscribing to Events

Return the event kinds you care about from subscriptions():

#![allow(unused)]
fn main() {
fn subscriptions(&self) -> Vec<EventKind> {
    vec![
        EventKind::MessageReceived,
        EventKind::MessageSent,
        EventKind::Custom("my-event".to_string()),
    ]
}
}

Use EventKind::All to receive every event (useful for logging/monitoring plugins).

Actions

Plugins return actions to request the runtime to do things on their behalf:

#![allow(unused)]
fn main() {
pub enum Action {
    /// Send a message through the transport.
    SendMessage { to: String, content: String },

    /// Persist a key-value pair in the plugin's scoped state.
    StoreState { key: String, value: serde_json::Value },

    /// Emit a custom event into the event bus.
    EmitEvent { kind: String, data: serde_json::Value },

    /// Structured log entry.
    Log { level: LogLevel, message: String },
}
}

Return multiple actions from a single event handler:

#![allow(unused)]
fn main() {
async fn handle_event(&self, event: &Event, ctx: &PluginContext) -> Result<Vec<Action>> {
    match event {
        Event::MessageReceived(msg) => Ok(vec![
            Action::SendMessage {
                to: msg.sender.clone(),
                content: "Got it!".into(),
            },
            Action::StoreState {
                key: "last_sender".into(),
                value: serde_json::json!(msg.sender),
            },
            Action::Log {
                level: LogLevel::Info,
                message: format!("Replied to {}", msg.sender),
            },
        ]),
        _ => Ok(vec![]),
    }
}
}

PluginContext

The PluginContext is a read-only snapshot passed to every event handler:

#![allow(unused)]
fn main() {
pub struct PluginContext {
    /// The agent's address on the transport (e.g. Algorand address).
    pub agent_address: String,
    /// The agent's display name.
    pub agent_name: String,
    /// Plugin-scoped state (read-only snapshot from StateStore).
    pub state: HashMap<String, serde_json::Value>,
    /// Plugin-specific config from nano.toml [plugins.<name>].
    pub config: toml::Table,
}
}

Reading State

State is scoped per plugin. Read from the snapshot in ctx.state:

#![allow(unused)]
fn main() {
async fn handle_event(&self, event: &Event, ctx: &PluginContext) -> Result<Vec<Action>> {
    let count = ctx.state
        .get("message_count")
        .and_then(|v| v.as_u64())
        .unwrap_or(0);

    // Increment and store
    Ok(vec![Action::StoreState {
        key: "message_count".into(),
        value: serde_json::json!(count + 1),
    }])
}
}

Reading Config

Plugin config comes from nano.toml:

[plugins.my-plugin]
api_key = "abc123"
max_retries = 3

Access it in your plugin:

#![allow(unused)]
fn main() {
async fn init(&mut self, ctx: &PluginContext) -> Result<()> {
    let api_key = ctx.config
        .get("api_key")
        .and_then(|v| v.as_str())
        .unwrap_or("default");

    let max_retries = ctx.config
        .get("max_retries")
        .and_then(|v| v.as_integer())
        .unwrap_or(5);

    // Store for later use
    Ok(())
}
}

Registering Plugins

Add your plugin to the runtime at startup:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use nano_runtime::{Runtime, RuntimeConfig};
use nano_transport::AlgoChatTransport; // or any Transport impl

let transport = Arc::new(AlgoChatTransport::new(/* ... */));
let config = RuntimeConfig {
    poll_interval_secs: 5,
    agent_name: "my-agent".into(),
    plugin_configs: HashMap::new(),
};

let mut runtime = Runtime::new(transport, config);

// Register plugins
runtime.add_plugin(Box::new(MyPlugin)).await?;
runtime.add_plugin(Box::new(AutoReplyPlugin::new())).await?;

// Run until shutdown
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
runtime.run(shutdown_rx).await?;
}

Built-in Plugins

Hub Plugin

Forwards messages to a corvid-agent-server and relays responses:

[plugins.hub]
url = "http://localhost:3578"

The hub plugin:

  1. Receives incoming AlgoChat messages
  2. Forwards them to the hub's A2A task endpoint
  3. Polls for the AI-generated response
  4. Sends the response back through the transport

Auto-Reply Plugin

Pattern-matching responder for when no AI hub is connected:

[plugins.auto-reply]
rules = [
    { match = "ping", reply = "pong" },
    { match = "status", reply = "online and operational" },
    { match = "help", reply = "Available commands: ping, status, help" },
]

Rules are matched case-insensitively as substrings. First match wins.

Plugin-to-Plugin Communication

Plugins can communicate through custom events:

Plugin A emits a custom event:

#![allow(unused)]
fn main() {
Ok(vec![Action::EmitEvent {
    kind: "price-update".into(),
    data: serde_json::json!({ "asset": "ALGO", "price": 0.42 }),
}])
}

Plugin B subscribes and reacts:

#![allow(unused)]
fn main() {
fn subscriptions(&self) -> Vec<EventKind> {
    vec![EventKind::Custom("price-update".to_string())]
}

async fn handle_event(&self, event: &Event, _ctx: &PluginContext) -> Result<Vec<Action>> {
    match event {
        Event::Custom { kind, data } if kind == "price-update" => {
            let price = data["price"].as_f64().unwrap_or(0.0);
            if price > 1.0 {
                Ok(vec![Action::SendMessage {
                    to: "admin".into(),
                    content: format!("ALGO price alert: ${}", price),
                }])
            } else {
                Ok(vec![])
            }
        }
        _ => Ok(vec![]),
    }
}
}

Testing Plugins

Use MockTransport for deterministic testing without a real blockchain:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use nano_runtime::{Runtime, RuntimeConfig};
use nano_transport::MockTransport;

#[tokio::test]
async fn test_my_plugin() {
    let transport = Arc::new(MockTransport::new("test-agent"));
    let mut runtime = Runtime::new(transport.clone(), RuntimeConfig::default());

    runtime.add_plugin(Box::new(MyPlugin)).await.unwrap();

    // Inject a message
    transport.inject(transport.message_from("alice", "hello"));

    // Run briefly then shut down
    let (tx, rx) = tokio::sync::watch::channel(false);
    tokio::spawn(async move {
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        let _ = tx.send(true);
    });
    runtime.run(rx).await.unwrap();

    // Verify what was sent
    let sent = transport.sent_messages();
    assert!(!sent.is_empty());
    assert_eq!(sent[0].to, "alice");
}
}

See tests/e2e_runtime.rs for 31 comprehensive examples.

Native vs WASM Plugins

Native (nano-runtime)WASM (plugin-host)
LanguageRust onlyAny language targeting WASM
SandboxingNone (runs in-process)Full sandbox (memory, CPU, network)
PerformanceNative speed, zero overheadSmall overhead from WASM runtime
CapabilitiesFull Rust ecosystemLimited to declared capabilities
Hot-reloadRestart requiredHot-reload with drain pattern
Use caseTrusted first-party pluginsThird-party/untrusted plugins
TestingStandard cargo testBuild to WASM, load in host

Choose native plugins for core agent behavior (hub forwarding, auto-reply, monitoring). Choose WASM plugins for third-party extensions where sandboxing matters.

Next Steps

Custom Transport

The transport abstraction lets you swap out how messages are sent and received without changing any plugin code. The default transport is AlgoChat (encrypted on-chain messaging via Algorand), but you can implement your own.

The Transport Trait

#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use nano_transport::{Message, OutboundMessage, SendResult, Transport};

#[async_trait]
pub trait Transport: Send + Sync {
    /// Human-readable name (e.g. "algochat", "websocket", "mqtt").
    fn name(&self) -> &str;

    /// Poll for new messages since the last sync.
    /// Return an empty vec if there are no new messages.
    async fn recv(&self) -> Result<Vec<Message>>;

    /// Send a message through this transport.
    async fn send(&self, msg: OutboundMessage) -> Result<SendResult>;

    /// The local agent's address on this transport.
    fn local_address(&self) -> &str;
}
}

Message Types

Inbound messages use the Message struct:

#![allow(unused)]
fn main() {
pub struct Message {
    pub sender: String,           // Who sent it
    pub recipient: String,        // Who it's for (your agent)
    pub content: String,          // Plaintext content (already decrypted)
    pub timestamp: DateTime<Utc>, // When received/confirmed
    pub metadata: serde_json::Value, // Transport-specific extras
}
}

Outbound messages use OutboundMessage:

#![allow(unused)]
fn main() {
pub struct OutboundMessage {
    pub to: String,      // Recipient address
    pub content: String, // Plaintext (transport handles encryption)
}
}

Send results return an ID:

#![allow(unused)]
fn main() {
pub struct SendResult {
    pub id: String, // Transport-assigned ID (tx hash, message ID, etc.)
}
}

Example: WebSocket Transport

Here's a complete example of a WebSocket-based transport. This uses external crates (tokio-tungstenite, futures-util) that you'd add to your own Cargo.toml:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::Mutex;
use nano_transport::{Message, OutboundMessage, SendResult, Transport};

pub struct WebSocketTransport {
    address: String,
    ws_url: String,
    inbox: Arc<Mutex<Vec<Message>>>,
}

impl WebSocketTransport {
    pub fn new(address: String, ws_url: String) -> Self {
        Self {
            address,
            ws_url,
            inbox: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Spawn a background listener that pushes messages into the inbox.
    pub async fn connect(&self) -> Result<()> {
        let inbox = self.inbox.clone();
        let url = self.ws_url.clone();
        let address = self.address.clone();

        tokio::spawn(async move {
            // Connect to WebSocket and push incoming messages to inbox
            // (implementation depends on your WS library)
            let (mut ws, _) = tokio_tungstenite::connect_async(&url)
                .await
                .expect("ws connect failed");

            use futures_util::StreamExt;
            while let Some(Ok(msg)) = ws.next().await {
                if let Ok(text) = msg.into_text() {
                    inbox.lock().await.push(Message {
                        sender: "ws-peer".into(),
                        recipient: address.clone(),
                        content: text,
                        timestamp: chrono::Utc::now(),
                        metadata: serde_json::Value::Null,
                    });
                }
            }
        });

        Ok(())
    }
}

#[async_trait]
impl Transport for WebSocketTransport {
    fn name(&self) -> &str {
        "websocket"
    }

    async fn recv(&self) -> Result<Vec<Message>> {
        let mut inbox = self.inbox.lock().await;
        let messages = inbox.drain(..).collect();
        Ok(messages)
    }

    async fn send(&self, msg: OutboundMessage) -> Result<SendResult> {
        // Send via WebSocket connection
        // (simplified — real impl would hold a write handle)
        Ok(SendResult {
            id: format!("ws-{}", uuid::Uuid::new_v4()),
        })
    }

    fn local_address(&self) -> &str {
        &self.address
    }
}
}

Example: HTTP Polling Transport

A simpler transport that polls an HTTP endpoint:

#![allow(unused)]
fn main() {
pub struct HttpTransport {
    address: String,
    poll_url: String,
    send_url: String,
    http: reqwest::Client,
    last_seen: Arc<Mutex<Option<String>>>,
}

#[async_trait]
impl Transport for HttpTransport {
    fn name(&self) -> &str {
        "http"
    }

    async fn recv(&self) -> Result<Vec<Message>> {
        let mut url = self.poll_url.clone();
        if let Some(cursor) = self.last_seen.lock().await.as_ref() {
            url = format!("{}?after={}", url, cursor);
        }

        let resp: Vec<Message> = self.http
            .get(&url)
            .send().await?
            .json().await?;

        if let Some(last) = resp.last() {
            *self.last_seen.lock().await = Some(
                last.metadata["id"].as_str().unwrap_or("").to_string()
            );
        }

        Ok(resp)
    }

    async fn send(&self, msg: OutboundMessage) -> Result<SendResult> {
        let resp = self.http
            .post(&self.send_url)
            .json(&msg)
            .send().await?;

        let id = resp.json::<serde_json::Value>().await?
            ["id"].as_str().unwrap_or("unknown").to_string();

        Ok(SendResult { id })
    }

    fn local_address(&self) -> &str {
        &self.address
    }
}
}

Built-in Transports

NullTransport

A no-op transport for offline mode and testing. recv() always returns empty, send() always succeeds.

#![allow(unused)]
fn main() {
use nano_transport::NullTransport;

let transport = NullTransport::new("my-address");
}

MockTransport

A test transport that lets you inject messages and capture outbound ones:

#![allow(unused)]
fn main() {
use nano_transport::MockTransport;

let transport = MockTransport::new("test-agent");

// Inject a message that recv() will return
transport.inject(transport.message_from("alice", "hello"));

// After running, check what was sent
let sent = transport.sent_messages();
assert_eq!(sent[0].to, "alice");

// Inject multiple messages at once
transport.inject_many(vec![
    transport.message_from("bob", "msg 1"),
    transport.message_from("charlie", "msg 2"),
]);

// Clear captured messages
transport.clear_sent();

// Check total send count (persists across clears)
assert_eq!(transport.send_count(), 1);
}

MockTransport::clone() shares state — both clones see the same inbox and outbox. This is useful for passing one clone to the runtime and keeping another for assertions.

Using Your Transport

Pass your transport to the runtime:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use nano_runtime::{Runtime, RuntimeConfig};

let transport = Arc::new(WebSocketTransport::new(
    "my-agent".into(),
    "ws://localhost:8080".into(),
));
transport.connect().await?;

let mut runtime = Runtime::new(transport, RuntimeConfig::default());
runtime.add_plugin(Box::new(MyPlugin)).await?;

let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
runtime.run(shutdown_rx).await?;
}

The runtime calls transport.recv() on every poll tick (default: every 5 seconds, configurable via RuntimeConfig::poll_interval_secs). Each returned message becomes a MessageReceived event dispatched to plugins.

Design Guidelines

  1. recv() should drain — return all pending messages and clear them. The runtime calls recv() on a timer; returning the same messages twice will cause duplicate processing.

  2. send() should be idempotent-safe — if the runtime retries a failed send, your transport should handle it gracefully.

  3. Use metadata for transport-specific data — round numbers, transaction hashes, channel IDs, etc. Plugins can read msg.metadata for transport-specific context without the transport leaking into the core API.

  4. Handle errors gracefully — the runtime logs transport errors and continues. Don't panic in recv() or send().

Next Steps

Examples & Demos

This page contains complete, runnable examples demonstrating common use cases for corvid-agent-nano.

Example 1: Echo Bot

The simplest possible agent — echoes back every message it receives.

Plugin Code

#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use nano_runtime::{Action, Event, EventKind, Plugin, PluginContext};

pub struct EchoPlugin;

#[async_trait]
impl Plugin for EchoPlugin {
    fn name(&self) -> &str { "echo" }
    fn version(&self) -> &str { "1.0.0" }

    async fn init(&mut self, _ctx: &PluginContext) -> Result<()> {
        Ok(())
    }

    async fn handle_event(
        &self,
        event: &Event,
        _ctx: &PluginContext,
    ) -> Result<Vec<Action>> {
        match event {
            Event::MessageReceived(msg) => Ok(vec![Action::SendMessage {
                to: msg.sender.clone(),
                content: format!("echo: {}", msg.content),
            }]),
            _ => Ok(vec![]),
        }
    }

    fn subscriptions(&self) -> Vec<EventKind> {
        vec![EventKind::MessageReceived]
    }
}
}

Running It

use std::sync::Arc;
use nano_runtime::{Runtime, RuntimeConfig};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let transport = Arc::new(/* your transport */);
    let mut runtime = Runtime::new(transport, RuntimeConfig::default());
    runtime.add_plugin(Box::new(EchoPlugin)).await?;

    let (_tx, rx) = tokio::sync::watch::channel(false);
    runtime.run(rx).await
}

Testing It

#![allow(unused)]
fn main() {
#[tokio::test]
async fn echo_replies_to_sender() {
    use std::sync::Arc;
    use nano_runtime::{Runtime, RuntimeConfig};
    use nano_transport::MockTransport;

    let transport = Arc::new(MockTransport::new("echo-agent"));
    let mut runtime = Runtime::new(transport.clone(), RuntimeConfig::default());
    runtime.add_plugin(Box::new(EchoPlugin)).await.unwrap();

    // Inject a message
    transport.inject(transport.message_from("alice", "hello"));

    // Run briefly
    let (tx, rx) = tokio::sync::watch::channel(false);
    tokio::spawn(async move {
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
        let _ = tx.send(true);
    });
    runtime.run(rx).await.unwrap();

    // Verify the reply
    let sent = transport.sent_messages();
    assert_eq!(sent.len(), 1);
    assert_eq!(sent[0].to, "alice");
    assert_eq!(sent[0].content, "echo: hello");
}
}

Example 2: Auto-Reply Bot with Config

A configurable keyword responder using nano.toml config.

nano.toml

[agent]
name = "support-bot"
network = "testnet"

[plugins.auto-reply]
rules = [
    { match = "ping", reply = "pong" },
    { match = "hours", reply = "We're available 9am-5pm UTC, Monday-Friday." },
    { match = "help", reply = "Commands: ping, hours, help, status" },
    { match = "status", reply = "All systems operational." },
]

Running It

#![allow(unused)]
fn main() {
use nano_runtime::plugins::auto_reply::AutoReplyPlugin;

let mut runtime = Runtime::new(transport, config);
runtime.add_plugin(Box::new(AutoReplyPlugin::new())).await?;
}

The auto-reply plugin reads its rules from ctx.config during init(). Rules match case-insensitively as substrings — "what are your hours?" matches the "hours" rule.

Testing It

#![allow(unused)]
fn main() {
#[tokio::test]
async fn auto_reply_responds_to_keywords() {
    let plugin = AutoReplyPlugin::with_rules(vec![
        ("ping".into(), "pong".into()),
        ("status".into(), "online".into()),
    ]);

    let ctx = PluginContext {
        agent_address: "test".into(),
        agent_name: "test".into(),
        state: Default::default(),
        config: Default::default(),
    };

    let msg = Event::MessageReceived(Message {
        sender: "alice".into(),
        recipient: "test".into(),
        content: "ping".into(),
        timestamp: chrono::Utc::now(),
        metadata: serde_json::Value::Null,
    });

    let actions = plugin.handle_event(&msg, &ctx).await.unwrap();
    assert_eq!(actions.len(), 1);
    match &actions[0] {
        Action::SendMessage { to, content } => {
            assert_eq!(to, "alice");
            assert_eq!(content, "pong");
        }
        _ => panic!("expected SendMessage"),
    }
}
}

Example 3: Stateful Counter Plugin

A plugin that counts messages per sender and persists the counts.

#![allow(unused)]
fn main() {
use anyhow::Result;
use async_trait::async_trait;
use nano_runtime::{Action, Event, EventKind, LogLevel, Plugin, PluginContext};

pub struct CounterPlugin;

#[async_trait]
impl Plugin for CounterPlugin {
    fn name(&self) -> &str { "counter" }
    fn version(&self) -> &str { "1.0.0" }

    async fn init(&mut self, _ctx: &PluginContext) -> Result<()> {
        Ok(())
    }

    async fn handle_event(
        &self,
        event: &Event,
        ctx: &PluginContext,
    ) -> Result<Vec<Action>> {
        match event {
            Event::MessageReceived(msg) => {
                // Read current count from state
                let key = format!("count:{}", msg.sender);
                let count = ctx.state
                    .get(&key)
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0);

                let new_count = count + 1;

                Ok(vec![
                    // Persist the updated count
                    Action::StoreState {
                        key,
                        value: serde_json::json!(new_count),
                    },
                    // Reply with the count
                    Action::SendMessage {
                        to: msg.sender.clone(),
                        content: format!(
                            "Message #{} from you. Total messages tracked.",
                            new_count
                        ),
                    },
                    // Log it
                    Action::Log {
                        level: LogLevel::Info,
                        message: format!(
                            "{} has sent {} messages",
                            msg.sender, new_count
                        ),
                    },
                ])
            }
            _ => Ok(vec![]),
        }
    }

    fn subscriptions(&self) -> Vec<EventKind> {
        vec![EventKind::MessageReceived]
    }
}
}

Key concept: State is read from ctx.state (a snapshot) and written via Action::StoreState. The updated value appears in the next event's context.


Example 4: Multi-Plugin Pipeline

Chain plugins together using custom events. This example implements a message filter + responder pipeline.

Filter Plugin

Validates incoming messages and emits a custom event for valid ones:

#![allow(unused)]
fn main() {
pub struct FilterPlugin {
    allowed_senders: Vec<String>,
}

#[async_trait]
impl Plugin for FilterPlugin {
    fn name(&self) -> &str { "filter" }
    fn version(&self) -> &str { "1.0.0" }

    async fn init(&mut self, ctx: &PluginContext) -> Result<()> {
        // Load allowed senders from config
        if let Some(toml::Value::Array(arr)) = ctx.config.get("allowed_senders") {
            self.allowed_senders = arr.iter()
                .filter_map(|v| v.as_str().map(String::from))
                .collect();
        }
        Ok(())
    }

    async fn handle_event(
        &self,
        event: &Event,
        _ctx: &PluginContext,
    ) -> Result<Vec<Action>> {
        match event {
            Event::MessageReceived(msg) => {
                if self.allowed_senders.contains(&msg.sender) {
                    // Forward valid messages as a custom event
                    Ok(vec![Action::EmitEvent {
                        kind: "validated-message".into(),
                        data: serde_json::json!({
                            "sender": msg.sender,
                            "content": msg.content,
                        }),
                    }])
                } else {
                    Ok(vec![Action::Log {
                        level: LogLevel::Warn,
                        message: format!("Blocked message from {}", msg.sender),
                    }])
                }
            }
            _ => Ok(vec![]),
        }
    }

    fn subscriptions(&self) -> Vec<EventKind> {
        vec![EventKind::MessageReceived]
    }
}
}

Responder Plugin

Only processes messages that passed the filter:

#![allow(unused)]
fn main() {
pub struct ResponderPlugin;

#[async_trait]
impl Plugin for ResponderPlugin {
    fn name(&self) -> &str { "responder" }
    fn version(&self) -> &str { "1.0.0" }

    async fn init(&mut self, _ctx: &PluginContext) -> Result<()> { Ok(()) }

    async fn handle_event(
        &self,
        event: &Event,
        _ctx: &PluginContext,
    ) -> Result<Vec<Action>> {
        match event {
            Event::Custom { kind, data } if kind == "validated-message" => {
                let sender = data["sender"].as_str().unwrap_or("unknown");
                let content = data["content"].as_str().unwrap_or("");

                Ok(vec![Action::SendMessage {
                    to: sender.to_string(),
                    content: format!("Validated and processed: {}", content),
                }])
            }
            _ => Ok(vec![]),
        }
    }

    fn subscriptions(&self) -> Vec<EventKind> {
        vec![EventKind::Custom("validated-message".to_string())]
    }
}
}

Config

[plugins.filter]
allowed_senders = ["alice", "bob", "ALGO_ADDRESS_HERE"]

Wiring It Up

#![allow(unused)]
fn main() {
let mut runtime = Runtime::new(transport, config);
runtime.add_plugin(Box::new(FilterPlugin { allowed_senders: vec![] })).await?;
runtime.add_plugin(Box::new(ResponderPlugin)).await?;
}

Example 5: Hub Forwarding (AI-Powered Agent)

Connect your nano agent to a corvid-agent-server for AI-powered responses.

Setup

# 1. Set up the agent
can setup

# 2. Fund it
can fund

# 3. Add the hub server as a contact
can contacts add \
  --name corvidagent \
  --address SERVER_ALGO_ADDRESS \
  --psk PSK_HEX_FROM_SERVER

# 4. Register with the hub
can register --hub-url http://localhost:3578

nano.toml

[agent]
name = "nano-scout"
network = "localnet"

[plugins.hub]
url = "http://localhost:3578"

Running

# Start the agent with hub forwarding
can run

The hub plugin automatically:

  1. Picks up AlgoChat messages from the transport
  2. Forwards them to POST {hub_url}/a2a/tasks/send
  3. Polls GET {hub_url}/a2a/tasks/{id} for the AI response
  4. Sends the response back on-chain to the original sender

Message Flow

Alice (on-chain) ──AlgoChat──▶ nano-agent ──HTTP──▶ corvid-agent-server
                                                          │
                                                    (AI processes)
                                                          │
Alice (on-chain) ◀──AlgoChat── nano-agent ◀──HTTP── response

Example 6: MCP Server (Claude Code Integration)

Expose your agent's messaging capabilities as tools in Claude Code or Cursor.

Setup

Add to your Claude Code config (~/.claude.json or project .claude/settings.json):

{
  "mcpServers": {
    "nano": {
      "command": "can",
      "args": ["mcp", "--data-dir", "/path/to/data"]
    }
  }
}

Or for Cursor (.cursor/mcp.json):

{
  "mcpServers": {
    "nano": {
      "command": "can",
      "args": ["mcp", "--network", "testnet"],
      "env": {
        "CAN_PASSWORD": "your-keystore-password"
      }
    }
  }
}

Available MCP Tools

Once configured, Claude Code / Cursor can use:

ToolDescription
send_messageSend an encrypted AlgoChat message to a contact
list_contactsList all PSK contacts
get_inboxView received messages (with optional filters)
check_balanceCheck the agent's ALGO balance
agent_infoGet agent identity, address, and network info

Example Interaction

In Claude Code:

User: Send a message to alice saying "meeting at 3pm"
Claude: [calls send_message tool with to="alice", message="meeting at 3pm"]
        Message sent to alice (tx: ABCD1234...)

Example 7: CLI Walkthrough (End-to-End)

A complete walkthrough of setting up two agents and having them communicate.

Terminal 1: Agent A

# Set up Agent A
can setup --generate --network localnet --password secret --data-dir ./agent-a
can fund --data-dir ./agent-a
can info --data-dir ./agent-a
# Note the Algorand address (e.g. AAAA...)

Terminal 2: Agent B

# Set up Agent B
can setup --generate --network localnet --password secret --data-dir ./agent-b
can fund --data-dir ./agent-b
can info --data-dir ./agent-b
# Note the Algorand address (e.g. BBBB...)

Exchange PSK Keys

# Generate a shared PSK (any 64-char hex string works)
openssl rand -hex 32
# Output: a1b2c3d4...64 chars

# Agent A adds Agent B as a contact
can contacts add \
  --name agent-b \
  --address BBBB_ADDRESS \
  --psk a1b2c3d4... \
  --data-dir ./agent-a

# Agent B adds Agent A as a contact
can contacts add \
  --name agent-a \
  --address AAAA_ADDRESS \
  --psk a1b2c3d4... \
  --data-dir ./agent-b

Start Both Agents

# Terminal 1
can run --data-dir ./agent-a

# Terminal 2
can run --data-dir ./agent-b

Send Messages

# From a third terminal, send from Agent A to Agent B
can send --to agent-b --message "Hello from Agent A!" --data-dir ./agent-a

# Check Agent B's inbox
can inbox --data-dir ./agent-b

Verify

# Check status of both agents
can status --data-dir ./agent-a
can status --data-dir ./agent-b

# View message history
can history --contact agent-b --data-dir ./agent-a

Example 8: Group Broadcast

Send a single message to multiple agents via a group channel.

# Create a group
can groups create --name team-alpha --data-dir ./agent-a

# Add members
can groups add-member --group team-alpha --member agent-b --data-dir ./agent-a
can groups add-member --group team-alpha --member agent-c --data-dir ./agent-a

# View group
can groups show --group team-alpha --data-dir ./agent-a

# Broadcast to all members
can send --to team-alpha --message "Team standup in 5 minutes" --data-dir ./agent-a

# List all groups
can groups list --data-dir ./agent-a

Quick Reference

Common Command Patterns

# Setup & wallet
can setup                          # Interactive wizard
can setup --generate --network testnet  # Non-interactive
can import --mnemonic "word1 word2 ..."  # Import existing
can info                           # Show agent details
can change-password                # Rotate keystore password

# Funding
can fund                           # Localnet faucet
can fund --network testnet         # Shows dispenser URL
can balance                        # Quick balance check

# Messaging
can send --to alice --message "hi" # Send direct message
can inbox                          # View all messages
can inbox --from alice             # Filter by sender
can history --contact alice        # Full history with contact

# Contacts
can contacts add --name X --address Y --psk Z
can contacts list
can contacts remove alice
can contacts export --output backup.json
can contacts import --file backup.json

# Agent
can run                            # Start message loop
can status                         # Health check
can register --hub-url URL         # Register with hub

# Plugins
can plugin list                    # List loaded plugins
can plugin invoke P tool '{}'      # Invoke a plugin tool

# Server modes
can mcp                            # MCP server (stdio)
can config                         # View/edit nano.toml

Environment Variables

All config can be set via environment variables with the CAN_ prefix:

export CAN_DATA_DIR=~/.corvid
export CAN_NETWORK=testnet
export CAN_PASSWORD=mysecret
export CAN_HUB_URL=http://localhost:3578
export CAN_LOG_LEVEL=debug
export CAN_LOG_FORMAT=json

Next Steps

MCP Integration: Use corvid-agent-nano with Claude Code and Cursor

Model Context Protocol (MCP) makes corvid-agent-nano available as a tool server to Claude-based IDEs and editors. This guide shows how to set up MCP integration with Claude Code and Cursor.

What You Get

Once configured, your Claude or Cursor assistant will have access to these capabilities:

  • Agent Info — Wallet address, contact count, cached messages
  • List Contacts — View all saved contacts
  • Get Inbox — Read recent messages (with optional filtering)
  • Check Balance — Look up your ALGO balance on-chain
  • Send Message — Compose and send encrypted AlgoChat messages

Your assistant can now draft messages, check your wallet status, and retrieve context from your message history without leaving the editor.

Prerequisites

  • can installed (Rust binary): cargo install --git https://github.com/CorvidLabs/corvid-agent-nano --bin can
  • A corvid-agent-nano wallet with can setup already completed
  • Claude Code, Cursor, or another MCP-compatible editor
  • Your wallet password or seed hex ready

Setup for Claude Code

Step 1: Locate Your Config

Claude Code reads MCP server configs from:

  • macOS/Linux: ~/.claude/mcp.json
  • Windows: %APPDATA%\Claude\mcp.json

If the file doesn't exist, create it.

Step 2: Add the Server Config

Add corvid-agent-nano to your mcp.json:

{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "localnet", "--password", "your_password_here"],
      "disabled": false
    }
  }
}

For testnet (real network):

{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "testnet", "--password", "your_password_here"],
      "disabled": false
    }
  }
}

Replace your_password_here with your actual keystore password.

Step 3: Restart Claude Code

Close and reopen Claude Code. The MCP server will start automatically.

Step 4: Verify Connection

Ask Claude to check your balance or list contacts. If it works, you're connected!

What's my current ALGO balance?

Setup for Cursor

Cursor uses the same MCP configuration format. Add the same config to:

  • macOS/Linux: ~/.cursor/mcp.json
  • Windows: %APPDATA%\Cursor\mcp.json

Then restart Cursor.

Security Best Practices

Avoid Plaintext Passwords

Instead of hardcoding your password in mcp.json, use an environment variable:

{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "testnet", "--password", "$CORVID_PASSWORD"],
      "disabled": false
    }
  }
}

Then set the variable in your shell profile:

macOS/Linux:

# Add to ~/.bashrc, ~/.zshrc, or ~/.profile
export CORVID_PASSWORD="your_password"

Windows (PowerShell):

[Environment]::SetEnvironmentVariable("CORVID_PASSWORD", "your_password", "User")

After adding it, restart your terminal and IDE.

Use Seed Instead of Password

For even better security, export your wallet seed hex and use --seed:

# First, get your seed hex from your wallet
can setup --show-seed

# Then update your MCP config
{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "testnet", "--seed", "$CORVID_SEED"],
      "disabled": false
    }
  }
}

And set CORVID_SEED in your environment.

Localnet for Development

When developing, use localnet instead of testnet — no real funds at risk:

{
  "mcpServers": {
    "corvid-agent-nano": {
      "command": "can",
      "args": ["mcp", "--network", "localnet", "--password", "$CORVID_PASSWORD"],
      "disabled": false
    }
  }
}

Requires: algokit localnet start running in the background.

Troubleshooting

MCP Server Not Starting

Symptom: Claude/Cursor doesn't recognize the tool.

Fix: Verify the can binary is in your PATH:

which can

If not found, install it:

cargo install --git https://github.com/CorvidLabs/corvid-agent-nano --bin can

"Network Unreachable" Errors

Symptom: Tools like check_balance and send_message fail with network errors.

Fix:

  • For localnet: Ensure algokit localnet start is running
  • For testnet: Check your internet connection and verify algod is accessible
  • Try a simpler tool first (e.g., agent_info) to isolate network issues

"Missing Password or Seed"

Symptom: send_message and check_balance fail with authentication errors.

Fix: Make sure --password or --seed is set in your MCP config. Note: agent_info, list_contacts, and get_inbox don't require authentication.

MCP Config Syntax Errors

Symptom: IDE complains about invalid JSON.

Fix: Validate your JSON:

jq . ~/.claude/mcp.json  # macOS/Linux

Common issues:

  • Trailing commas in JSON arrays/objects
  • Unescaped backslashes in paths
  • Single quotes instead of double quotes

Advanced: Multiple Instances

If you have multiple wallets or networks, configure multiple servers:

{
  "mcpServers": {
    "corvid-agent-nano-localnet": {
      "command": "can",
      "args": ["mcp", "--network", "localnet", "--password", "$CORVID_PASSWORD_LOCAL"],
      "disabled": false
    },
    "corvid-agent-nano-testnet": {
      "command": "can",
      "args": ["mcp", "--network", "testnet", "--password", "$CORVID_PASSWORD_TESTNET"],
      "disabled": false
    }
  }
}

This lets you switch between networks by asking your assistant which one to use.

Examples

Get Wallet Status

What's my ALGO balance and how many contacts do I have?

Send a Message

Send a message to alice@example saying "Hello from Claude!"

Check Recent Messages

Show me my last 5 messages from bob.

List All Contacts

Give me a list of all my contacts with their addresses.

Demo Walkthrough

Step-by-step scenarios for demonstrating can in action.

Quick Demo (5 minutes)

Run these commands in order for a fast overview of all features:

# Start localnet
algokit localnet start

# Set up and fund
can setup --generate --network localnet --password demo
can fund
can status
can info

# Configure
can config set agent.name "demo-bot"
can config show

# Groups
can groups create --name demo-team
can groups list

# Run the agent
can run --no-hub --health-port 9090 &

# Health check
curl -s localhost:9090/health | jq

# Plugins
can plugin list

Two Agents Communicating

This demo requires two terminals.

Setup

Terminal 1 — Agent Alpha:

mkdir -p /tmp/alpha && cd /tmp/alpha
can setup --generate --network localnet --password alpha123
can fund
can info   # Copy the address

Terminal 2 — Agent Beta:

mkdir -p /tmp/beta && cd /tmp/beta
can setup --generate --network localnet --password beta123
can fund
can info   # Copy the address

Exchange Contacts

Generate a shared PSK:

openssl rand -hex 32

Terminal 1:

can contacts add --name beta --address <BETA_ADDRESS> --psk <SHARED_PSK>

Terminal 2:

can contacts add --name alpha --address <ALPHA_ADDRESS> --psk <SHARED_PSK>

Send Messages

Terminal 2 — Start listening:

can run --no-hub --password beta123

Terminal 1 — Send:

can send --to beta --message "Hello from Alpha!" --password alpha123

Beta's terminal will log the incoming message. Verify with:

can inbox

MCP with Claude Code

Start can as an MCP server and connect it to Claude Code:

can mcp --password mypassword

Add to Claude Code config:

{
  "mcpServers": {
    "nano": {
      "command": "can",
      "args": ["mcp", "--password", "mypassword"]
    }
  }
}

Available tools: agent_info, list_contacts, get_inbox, check_balance, send_message.

Plugin System

# Build the example plugin
cargo build -p hello-world-plugin --target wasm32-wasip1 --release
cp target/wasm32-wasip1/release/hello_world_plugin.wasm data/plugins/

# Start agent with plugins
can run &

# Use plugins
can plugin list
can plugin invoke hello-world hello '{"name": "World"}'
can plugin health

Production Features

# Health monitoring
can run --health-port 9090

# JSON logging for log aggregation (--log-format is a global flag)
can --log-format json run --health-port 9090

# Check health
curl -s http://localhost:9090/health | jq

For more detailed scenarios, see the DEMO.md file in the repository root.

Architecture Overview

Project structure

corvid-agent-nano/
├── src/
│   ├── main.rs              # CLI entry point + command handlers
│   ├── ui.rs                # Terminal colors and formatting
│   ├── wizard.rs            # Interactive setup wizard
│   ├── agent.rs             # Message loop + hub forwarding
│   ├── algorand.rs          # HTTP clients (algod, indexer)
│   ├── contacts.rs          # PSK contact management (SQLite)
│   ├── groups.rs            # Group channel management (SQLite)
│   ├── keystore.rs          # Encrypted wallet (Argon2id + ChaCha20-Poly1305)
│   ├── storage.rs           # Key storage + message cache (SQLite)
│   ├── transaction.rs       # Algorand transaction building/signing
│   ├── wallet.rs            # Wallet generation + mnemonics
│   ├── bridge.rs            # JSON-RPC plugin host client
│   └── sidecar.rs           # Plugin host process manager
├── crates/
│   ├── corvid-plugin-sdk/   # WASM plugin SDK
│   ├── corvid-plugin-host/  # Plugin runtime host
│   ├── corvid-plugin-cli/   # Plugin CLI tools
│   └── corvid-plugin-macros/# Proc macros for plugins
├── plugins/                 # Example plugins
├── specs/                   # Module specifications
└── docs/                    # This documentation (mdBook)

Message flow

                    ┌─────────────┐
                    │   Algorand  │
                    │  Blockchain │
                    └──────┬──────┘
                           │ AlgoChat txns
                    ┌──────▼──────┐
                    │  can agent  │
                    │  (message   │
                    │   loop)     │
                    └──┬──────┬───┘
                       │      │
              ┌────────▼─┐  ┌─▼────────┐
              │  SQLite   │  │   Hub    │
              │  (cache)  │  │  (A2A)   │
              └───────────┘  └──────────┘

Key dependencies

CratePurpose
algochatAlgoChat protocol (encryption, key exchange, message format)
clapCLI argument parsing with derive macros
tokioAsync runtime
rusqliteSQLite for contacts, groups, messages
argon2Password hashing (Argon2id)
chacha20poly1305Authenticated encryption
ed25519-dalekEdDSA signing for Algorand transactions
wasmtimeWebAssembly plugin runtime
dialoguerInteractive terminal prompts
coloredTerminal color output

Security Model

Overview

corvid-agent-nano implements defense-in-depth across wallet storage, message encryption, runtime isolation, and plugin sandboxing. This document details the security assumptions, threat model, and protections.

Threat Model

What we protect against:

  • Disk compromise — Attacker gains access to files but not memory
  • Man-in-the-middle (MITM) — Attacker intercepts network traffic
  • Malicious plugins — Untrusted WASM code running on the agent
  • Accidental exposure — Inadvertent logging of secrets
  • Blockchain tampering — Adversary modifies transaction data on-chain

What we assume:

  • Trusted initial setup — You control your wallet at creation time
  • Secure password — Your keystore password is reasonably strong (8+ chars)
  • Safe running environment — The machine running the agent is not compromised
  • HTTPS for production — Hub communication should use HTTPS in production (not just HTTP)

Wallet Encryption

Your Ed25519 signing key (the seed) is stored in an encrypted keystore file (keystore.enc):

Encryption scheme

Plaintext (seed)
    ↓
[Argon2id KDF] → Derived key (256 bits)
    ↓
[ChaCha20-Poly1305 cipher] → Ciphertext + tag
    ↓
Keystore file (JSON)

Parameters

  • KDF algorithm: Argon2id
    • Memory cost: 64 MiB
    • Time cost: 3 iterations
    • Parallelism: 1 thread
    • Purpose: Maximize resistance to GPU/ASIC brute-force attacks
  • Cipher: ChaCha20-Poly1305 (AEAD)
    • Authenticated encryption — tampering detected
    • Stream cipher — efficient and constant-time
  • Salt: 16 random bytes (per keystore)
    • Prevents rainbow-table attacks
  • Nonce: 12 random bytes (per keystore)
    • Ensures unique ciphertext for same plaintext/key
  • File permissions: 0600 (Unix only)
    • Readable/writable by owner only

Keystore format

The keystore is JSON:

{
  "argon2id": {
    "memory_cost": 65536,
    "time_cost": 3,
    "parallelism": 1,
    "salt": "hex-encoded-salt"
  },
  "nonce": "hex-encoded-nonce",
  "ciphertext": "hex-encoded-ciphertext",
  "address": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}

The address is stored in plaintext to allow identification without decryption (e.g., for backup/recovery).

Protection analysis

  • Offline attacks: Argon2id's high memory cost makes GPU/ASIC attacks impractical
  • Online attacks: Each failed password attempt requires 64 MiB of memory
  • Side channels: ChaCha20-Poly1305 is constant-time (no timing leaks)

Recovery

If your keystore password is lost, you can recover from your 25-word recovery phrase:

rm ./data/keystore.enc
can import --mnemonic "word1 word2 ... word25" --password "new_password"

Keep your recovery phrase safe — it's the only way to restore your wallet.

Message Encryption

AlgoChat protocol

Messages between agents are encrypted end-to-end using a pre-shared key (PSK) model:

Alice               Algorand Blockchain      Bob
  │                         │                 │
  ├─ Encrypt(message)       │                 │
  │  with PSK                │                 │
  ├─ Send transaction───────→│                 │
  │  (ciphertext in note)     │                 │
  │                           │───→ Read tx    │
  │                           │─ Decrypt      │
  │                           │  with PSK     │
  │                           ✓ Message      │

Encryption scheme

  • Ephemeral key exchange: X25519 Diffie-Hellman (one key per message)
    • Provides forward secrecy — compromising one key doesn't reveal past messages
    • Uses the shared PSK as input to derive session keys
  • Authenticated encryption: ChaCha20-Poly1305
    • Both confidentiality and authentication in one operation
    • Tampering is cryptographically detected (Poly1305 authentication tag)
  • Transport: Ciphertext stored as Algorand transaction note field
    • 1 KB size limit per note — messages are chunked if necessary
    • Immutable on-chain — provides a permanent audit trail

Forward secrecy

Each message uses a unique ephemeral key. This means:

  • Even if an attacker obtains your PSK, past messages remain encrypted
  • Each message can be decrypted independently

Pre-shared key (PSK) management

PSKs are established out-of-band (not over the network):

# Agent A generates or provides a PSK:
can groups create --name trusted-group

# Agent B adds Agent A as a contact with that PSK:
can contacts add --name agent-a --address <ADDRESS> --psk <PSK>

Best practices for PSKs:

  • Generate with cryptographically strong RNG (not by hand!)
  • Share securely (encrypted email, secure messaging, in-person)
  • Use unique PSKs for each contact/group
  • Rotate PSKs periodically if long-lived relationships

Memory Safety

Sensitive data is carefully managed in memory:

Zeroization

  • Seeds, keys, and passwords are zeroized after use via the zeroize crate
  • Prevents sensitive data from leaking in RAM dumps or core files
  • Applied to:
    • Ed25519 signing keys
    • X25519 ephemeral keys
    • Derived encryption keys
    • Plaintext seeds during import

Runtime constraints

  • Passwords: Never stored in memory — only used once during KDF
  • Keys: Held in memory only while needed
    • Signing key: loaded only when signing transactions
    • Decryption key: loaded only when decrypting messages
    • Session keys: zeroized after message processing
  • Recovery phrases: Immediately zeroized after import

Plugin Sandboxing

WASM plugins run in a sandboxed WebAssembly environment with strict isolation:

Sandbox isolation

  • Memory sandbox: Each plugin has isolated linear memory (32-512 MiB depending on tier)
    • No direct access to agent memory
    • No access to other plugins' memory
  • Table sandbox: Function table capped at 64K entries
    • Prevents unbounded table growth
  • Capability model: Plugins access only JSON-RPC tools they're granted
    • No direct filesystem access
    • No direct network access
    • No access to runtime internals

Trust tiers

Plugins are classified by trustworthiness:

TierMemoryTimeoutCPU limitUse case
trusted512 MiB60sUnlimitedFirst-party, audited, critical functionality
verified128 MiB30s100%Third-party code-reviewed plugins
untrusted32 MiB10s50%Unknown/unreviewed plugins (default)

CPU limits prevent denial-of-service attacks (e.g., infinite loops freezing the agent).

Available capabilities

Plugins can request access to:

  1. Messaging — Send/receive encrypted messages
  2. Storage — Key-value store (plugin-isolated)
  3. Algorand — Query chain state, build transactions
  4. HTTP — Make outbound HTTP requests (with URL allowlist)

Plugins cannot:

  • Access the keystore or encryption keys
  • Read other plugins' storage
  • Execute arbitrary native code
  • Access the filesystem
  • Open raw network sockets

Resource limits

Per plugin:

  • Memory: Enforced by WebAssembly linear memory limit
  • CPU: Execution timeout per tool invocation
  • Table growth: Capped at 64K function entries
  • Storage: Per-plugin isolation (no shared state leakage)

Attestation and logging

When a plugin is loaded with a tier, it's logged (with plugin ID and hash):

2026-03-30 10:15:42 Loaded plugin hello-world (trusted) hash=sha256:abc123...

This audit trail helps track which plugins are installed.

Network Security

Logging practices

  • API tokens: Algod/indexer auth tokens are never logged
  • Encryption keys: Public keys logged in truncated form only (first 16 hex chars)
    • Example: pub_key=5a1b2c3d4e5f6a7b... (not the full key)
  • Passwords: Never logged (not even as ****)
  • Seeds: Never logged

Hub communication

  • Default: HTTP (cleartext)
  • Recommended for production: HTTPS (encrypted)
  • Authentication: Pre-shared key (PSK) contact ensures both sides are known

If using testnet or mainnet, always use HTTPS for hub communication to prevent MITM attacks.

Algorand node security

  • Consider running your own Algorand node for privacy
  • If using public nodes, your queries reveal which addresses/transactions you're interested in
  • Public nodes should be accessed over HTTPS

Cryptographic Primitives

Algorithms used

AlgorithmPurposeWhy this choice
Ed25519Wallet signingFast, secure, no recovery parameter issues
X25519Key exchangePost-quantum resistant forward secrecy
ChaCha20-Poly1305Message encryptionFast, constant-time, AEAD (authenticated encryption)
Argon2idPassword hashingMemory-hard, GPU/ASIC resistant
SHA-256Hashing (if needed)Standard, widely audited

All algorithms are well-established and battle-tested. The combinations form a conservative, time-tested security architecture.

NIST guidance

  • Ed25519: NIST approved (RFC 8032, FIPS 186-5)
  • ChaCha20-Poly1305: NIST approved (RFC 7539, FIPS 800-38D)
  • Argon2id: OWASP recommended for password hashing (2023)

Auditing and Vulnerabilities

CI/CD security

  • GitHub Actions: Least-privilege permissions (contents: read)
  • Dependency scanning: cargo audit checks for known vulnerabilities
  • Static analysis: CodeQL scans for common security issues
  • Spec validation: specsync ensures code matches security specifications

Reporting vulnerabilities

If you discover a security issue:

  1. Do not open a public GitHub issue
  2. Email security@corvidlabs.io with:
    • Vulnerability description
    • Affected version(s)
    • Reproduction steps (if possible)
    • Your suggested fix (if any)

We will:

  • Acknowledge within 48 hours
  • Assess severity and impact
  • Develop a fix
  • Release a patched version
  • Credit you in the security advisory

Best Practices for Users

  1. Keep your recovery phrase safe — Store offline, not in files/email
  2. Use a strong password — 12+ characters, mix of letters/numbers/symbols
  3. Use HTTPS for hub communication — Especially on testnet/mainnet
  4. Audit plugins before installing — Review the WASM source or trust the author
  5. Run your own Algorand node — For privacy and reliability
  6. Rotate PSKs periodically — For long-lived agent relationships
  7. Keep Rust updated — Security patches are released regularly

Security Limitations

  • Hot wallet — Your signing key is in memory while the agent runs
    • For high-security use cases, consider air-gapped signing
  • HTTP by default — Hub communication is unencrypted by default
    • Use HTTPS in production
  • Trust on first use (TOFU) — PSKs establish trust but can be compromised if shared insecurely
  • Local to host — Agent doesn't protect against host compromise

These are acceptable trade-offs for a lightweight, user-friendly agent.

Data Storage

All persistent data is stored in the --data-dir directory (default: ./data).

Files

FileFormatPurpose
keystore.encJSONEncrypted wallet (Argon2id + ChaCha20-Poly1305)
contacts.dbSQLitePSK contacts
groups.dbSQLiteGroup channels and members
keys.dbSQLiteAlgoChat key storage (DH session keys)
messages.dbSQLiteMessage cache (inbox)
plugins/DirectoryWASM plugin files

Contacts database

Schema:

CREATE TABLE contacts (
    name TEXT PRIMARY KEY,
    address TEXT NOT NULL,
    psk BLOB NOT NULL,
    added_at TEXT NOT NULL
);

Groups database

Schema:

CREATE TABLE groups (
    name TEXT PRIMARY KEY,
    psk BLOB NOT NULL,
    created_at TEXT NOT NULL
);

CREATE TABLE group_members (
    group_name TEXT NOT NULL,
    address TEXT NOT NULL,
    label TEXT,
    added_at TEXT NOT NULL,
    PRIMARY KEY (group_name, address),
    FOREIGN KEY (group_name) REFERENCES groups(name)
);

Message cache

Messages are cached locally when the agent runs. The cache stores:

  • Message ID, sender, recipient
  • Decrypted content
  • Timestamp and confirmed round
  • Direction (sent/received)
  • Reply-to information

Backup

# Backup contacts and groups
can contacts export --output contacts.json
can groups export --output groups.json

# Copy keystore
cp ./data/keystore.enc ~/backup/

# Restore
can contacts import contacts.json
can groups import groups.json

Reset

To completely reset:

rm -rf ./data
can setup

Environment Variables

All environment variables are optional. CLI flags take precedence.

Network configuration

VariableDescriptionDefault
CAN_NETWORKNetwork preset: localnet, testnet, mainnetlocalnet
CAN_ALGOD_URLOverride algod URLfrom network
CAN_ALGOD_TOKENOverride algod API tokenfrom network
CAN_INDEXER_URLOverride indexer URLfrom network
CAN_INDEXER_TOKENOverride indexer API tokenfrom network

Identity

VariableDescriptionDefault
CAN_SEEDAgent seed (hex-encoded 32 bytes)from keystore
CAN_ADDRESSAgent Algorand addressfrom keystore
CAN_PASSWORDKeystore passwordinteractive prompt

Logging

VariableDescriptionDefault
RUST_LOGLog level filterinfo

Examples:

# Show debug logs
RUST_LOG=debug can run

# Show only warnings
RUST_LOG=warn can run

# Module-specific logging
RUST_LOG=corvid_agent_nano=debug,algochat=info can run

Docker / CI usage

CAN_NETWORK=testnet \
CAN_PASSWORD=mypassword \
CAN_SEED=aabbccdd... \
CAN_ADDRESS=ALGO_ADDRESS... \
can run

Troubleshooting

Common issues

"No wallet found. Run can init first"

You haven't set up a wallet yet:

can setup

Or you're pointing at the wrong data directory:

can info --data-dir /path/to/your/data

"Wallet already exists"

A keystore already exists. To start fresh:

rm ./data/keystore.enc
can setup

"Contact already exists"

Use --force to overwrite:

can contacts add --name alice --address ... --psk ... --force

"Decryption failed -- wrong password?"

The keystore password is incorrect. If you've forgotten it, you'll need to re-import from your recovery phrase:

rm ./data/keystore.enc
can import --mnemonic "your 25 words..."

No messages received

Check:

  1. Both agents are on the same network (localnet/testnet/mainnet)
  2. Both agents have each other as PSK contacts with the same key
  3. The sending agent has sufficient ALGO balance for transactions
  4. Run can status to verify connectivity

Hub unreachable

can status

Check the "Hub" line. Verify:

  • The hub is running at the expected URL
  • No firewall blocking the connection
  • The --hub-url flag matches the hub's actual address

Transaction failures

Usually means insufficient balance:

can fund  # localnet

Or check your balance:

can status

Plugin host not responding

The plugin host is a separate process. Check:

can plugin health

If it's not running, restart the agent:

can run

Balance is very low

On localnet:

can fund --amount 100000000  # 100 ALGO

On testnet, use the dispenser.

Debug logging

Enable verbose logging:

RUST_LOG=debug can run

For specific modules:

RUST_LOG=corvid_agent_nano::agent=debug can run

Getting help

Frequently Asked Questions

Installation & Setup

How do I install corvid-agent-nano?

Two options:

From crates.io (recommended):

cargo install corvid-agent-nano

From source:

git clone https://github.com/CorvidLabs/corvid-agent-nano.git
cd corvid-agent-nano
cargo install --path .

The binary is called can.

What version of Rust do I need?

Rust 1.75 or later. Check your version:

rustc --version

Update with:

rustup update

Do I need to run a local Algorand node?

For localnet (default): Yes, use AlgoKit:

algokit localnet start

For testnet/mainnet: No, the agent uses public nodes. You can optionally run your own node for privacy.

How do I choose a network?

# localnet (for testing, default)
can setup --network localnet

# testnet (for staging/testing with real Algo)
can setup --network testnet

# mainnet (for production)
can setup --network mainnet

Localnet is free and instant. Testnet uses real Algo (get from faucet).

Wallets & Keys

What's the difference between setup and import?

  • can setup — Create a new wallet and generate a recovery phrase
  • can import — Import an existing wallet from a recovery phrase or seed

Use import if you already have a wallet you want to restore.

What's a recovery phrase (mnemonic)?

A 25-word phrase that uniquely identifies your wallet. It's the only way to recover your wallet if you lose your keystore file or forget your password.

Protect it:

  • Write it down on paper
  • Store offline (not in files or email)
  • Never share it
  • If compromised, move your funds to a new wallet immediately

What if I lose my recovery phrase?

If you've already saved your keystore file and know your password, you're fine. The recovery phrase only matters if you lose the keystore.

To prevent disaster, write it down when you first create your wallet:

can setup
# The recovery phrase is displayed — write it down!

How do I change my password?

can change-password --data-dir ~/.corvid

This creates a new encrypted keystore with the new password. Your recovery phrase doesn't change.

Can I export my seed or recovery phrase later?

Not with the current CLI. If you need it, restore from your written-down recovery phrase:

can import --mnemonic "word1 word2 ... word25" --password new_password

What does "Wallet already exists" mean?

A keystore file already exists in your data directory. To start fresh:

rm ~/.corvid/keystore.enc
can setup

This destroys the old wallet — make sure you have a backup recovery phrase!

Messaging

Why aren't I receiving messages?

Check:

  1. Same network? Both agents must be on localnet/testnet/mainnet

    can info  # Check your network
    
  2. Mutual contacts? Both agents must have each other as PSK contacts

    can contacts list
    
  3. Same PSK? Both must use the exact same pre-shared key

    • If not, regenerate one:
    can groups create --name shared
    # Share the PSK with the other agent
    
  4. Running? The agent must be running to receive messages

    can run
    
  5. Funded? Your account must have ALGO for transactions

    can fund  # On localnet, auto-funds from faucet
    

What's a pre-shared key (PSK)?

A 32-byte secret shared between two agents that enables encrypted messaging. It's established out-of-band (you share it manually, not over the network).

Generate one with:

can groups create --name my-group
# Copy the PSK and share securely

Provide it as hex (64 chars) or base64 (44 chars):

can contacts add --name alice --address ALGO_ADDRESS --psk <PSK>

Can I send messages without a hub?

Yes, use P2P mode:

can run --no-hub

This stores messages locally without forwarding to a hub. Useful for testing or direct agent-to-agent communication.

What if my hub is unreachable?

Check connectivity:

can status

The output shows hub reachability. If unreachable:

  • Is the hub running? can run on the hub machine
  • Is the URL correct? Check --hub-url flag (default: http://localhost:3578)
  • Firewall blocking? Allow traffic on the hub port

For production, use HTTPS to prevent MITM attacks.

Can I send messages to multiple agents?

Yes, use groups:

can groups create --name team
can groups add-member --group team --address ALICE...
can groups add-member --group team --address BOB...

# Send to all members at once
can send --to team --message "Hello team!"

Each member of the group can decrypt (they all share the group PSK).

Hub Integration

How do I connect to a corvid-agent hub?

  1. Get the hub's Algorand address from its administrator
  2. Create a PSK contact on the hub via API:
    curl -X POST http://hub:3000/api/algochat/psk/contacts \
      -H "Content-Type: application/json" \
      -d '{"name": "my-agent", "address": "YOUR_ADDRESS"}'
    
  3. Add the hub as a contact on your side:
    can contacts add --name hub \
      --address HUB_ADDRESS \
      --psk PSK_FROM_STEP_2
    
  4. Run with hub forwarding:
    can run --hub-url http://hub:3578
    

The agent will forward messages to the hub for processing.

What does "hub forwarding" mean?

When the agent receives a message:

  1. It decrypts the message
  2. Sends it to the hub's A2A endpoint
  3. Waits for the hub to process (AI reasoning, tool calls, etc.)
  4. Encrypts the hub's response
  5. Sends the response back on-chain

Without a hub, messages are just stored locally.

Can I run without a hub?

Yes:

can run --no-hub

The agent will receive and store messages but won't forward them anywhere. Useful for testing or dedicated agent instances.

Plugins

What are plugins?

WASM modules that extend agent capabilities. They can:

  • Send/receive messages
  • Query blockchain
  • Make HTTP requests
  • Store data
  • Define custom tools

Load them with:

can plugin load my-plugin.wasm
can plugin invoke my-plugin my-tool '{"param": "value"}'

How do I create a plugin?

See Plugin Development Guide for a complete walkthrough.

Quick start:

cargo new --lib my-plugin
# Edit Cargo.toml and src/lib.rs
cargo build --target wasm32-wasip1 --release
cp target/wasm32-wasip1/release/my_plugin.wasm ~/.corvid/plugins/
can run
can plugin list

What trust tiers mean?

When loading a plugin, specify how much you trust it:

TierMemoryTimeoutUse
trusted512 MiB60sYour own plugins
verified128 MiB30sCode-reviewed plugins
untrusted32 MiB10sUnknown plugins (default)
can plugin load my-plugin.wasm --tier trusted

Higher-trust plugins get more resources. Always use the least-permissive tier.

Can plugins access my wallet or keys?

No. Plugins run in a sandbox with no access to:

  • Your keystore or signing key
  • Your recovery phrase
  • Other plugins' storage
  • Raw network or filesystem

They can only use JSON-RPC APIs (messaging, storage, HTTP, Algorand queries).

How do I disable plugins?

can run --no-plugins

Plugins won't be loaded or executed.

Can plugins run code from the internet?

No, plugins are WASM binaries you install locally. They can't download code at runtime.

However, plugins can make HTTP requests to external APIs:

ctx.http_get("https://api.example.com/data")

The agent should maintain an HTTP allowlist for security.

Data & Storage

Where is my data stored?

By default: ./data/ (relative to current directory)

Change the location:

can setup --data-dir ~/.corvid
can run --data-dir ~/.corvid

Directory structure:

~/.corvid/
├── keystore.enc       # Encrypted wallet
├── contacts.db        # SQLite contacts
├── messages.db        # SQLite message cache
└── plugins/           # WASM plugins

How do I back up my wallet?

Save your recovery phrase:

can setup
# Write down the 25-word phrase

You can also copy the keystore file:

cp ~/.corvid/keystore.enc ~/backup/

To restore:

can import --mnemonic "your 25 words..."

Can I use the same wallet on multiple machines?

Yes:

  1. Get recovery phrase from first machine:

    # It's shown during setup — write it down if you didn't
    
  2. Import on second machine:

    can setup --network testnet --mnemonic "your 25 words..."
    

Both machines will use the same Algorand address but have separate keystores (encrypted with different passwords).

Is my data encrypted?

  • Keystore — Yes (Argon2id + ChaCha20-Poly1305)
  • Messages — Yes (in transit, ChaCha20-Poly1305)
  • Contacts — No, stored in plaintext SQLite
  • Plugins — No, loaded in plaintext

Consider full-disk encryption for production machines.

Can I export my contacts?

can contacts export --output contacts.json

This creates a JSON file with all your contacts (cleartext).

Import on another machine:

can contacts import --file contacts.json

Troubleshooting

Agent crashes on startup

Check logs:

RUST_LOG=debug can run

Common causes:

  • Corrupted database — Delete ~/.corvid/*.db and re-run
  • Missing algod/indexer — Start localnet: algokit localnet start
  • Wrong network — Check --network flag

"Decryption failed — wrong password?"

Your password is incorrect. Options:

  1. Try the correct password
  2. If forgotten, restore from recovery phrase:
    rm ~/.corvid/keystore.enc
    can import --mnemonic "your 25 words..."
    

"Contact already exists"

Use --force to overwrite:

can contacts add --name alice --address ADDR --psk KEY --force

Transaction failures

Common causes:

  • Insufficient balance — Fund your account: can fund
  • Wrong network — Ensure agent and contacts are on same network
  • Transaction too large — Messages > 1 KB are chunked automatically

Plugin timeouts

If a plugin tool times out:

  • It took longer than the tier's limit (10-60s)
  • Optimize the code or break it into smaller tasks
  • Consider a higher trust tier for more time

"Can't connect to Algorand node"

Localnet users:

algokit localnet start

Testnet/mainnet users:

  • Check internet connection
  • Verify node URL is correct
  • Try a different public node

CPU usage is high

Likely causes:

  • Agent polling too frequently — Use larger --poll-interval
  • Plugin running expensive computation — Optimize or reduce frequency
  • Hub unreachable causing retries — Fix hub connection

Try:

can run --poll-interval 10

Features & Roadmap

What can the agent do?

See Introduction for full feature list. Quick summary:

  • Send/receive encrypted messages
  • Hub integration (forward to AI)
  • P2P direct agent-to-agent communication
  • Group channels for broadcasting
  • WASM plugins for extending capabilities
  • Contact and wallet management
  • Multi-network support (localnet/testnet/mainnet)

Can the agent sign transactions?

Yes, it can build and submit Algorand transactions. See Plugin Development Guide for the ctx.submit_transaction() API.

Can the agent access smart contracts?

Through the Algorand query APIs (via plugins). No direct smart contract calling yet, but you can query contract state.

What's planned next?

See GitHub issues for upcoming features and vote on your favorites!

Contributing

How do I report a bug?

Create a GitHub Issue with:

  • Steps to reproduce
  • Expected vs actual behavior
  • Version: can --version
  • Logs: RUST_LOG=debug can run

How do I suggest a feature?

Open a GitHub Discussion to discuss before implementing.

How do I contribute code?

See CONTRIBUTING.md for full guidelines. Quick summary:

  1. Fork the repo
  2. Create a branch (feature/name)
  3. Make changes and test
  4. Open a PR
  5. Address review feedback

Getting Help

  • Documentation — You're reading it!
  • IssuesGitHub Issues for bugs
  • DiscussionsGitHub Discussions for questions
  • Security — security@corvidlabs.io for vulnerability reports

More Questions?

Can't find an answer? Open a discussion — we're here to help!