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.
- Repository: github.com/CorvidLabs/corvid-agent-nano
- License: MIT
- Platform: corvid-agent
Installation
From crates.io (recommended)
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:
- Network selection (localnet/testnet/mainnet)
- Wallet creation (generate new or import existing)
- 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?
- Connect to a hub for AI-powered responses
- Set up group channels for broadcasting
- Run in P2P mode without a hub
- Install plugins to extend capabilities
- Build native plugins with the event-driven runtime
- Write a custom transport for non-Algorand backends
- Examples & demos for complete walkthroughs
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:
- Network -- localnet, testnet, or mainnet
- Wallet -- generate a new wallet or import an existing one
- 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
| Flag | Description |
|---|---|
--network | Network preset: localnet, testnet, mainnet |
--generate | Generate a new wallet (non-interactive) |
--mnemonic | Import from 25-word Algorand mnemonic |
--seed | Import from hex-encoded 32-byte Ed25519 seed |
--password | Password for keystore encryption (min 8 chars) |
--data-dir | Data 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.
| Service | URL |
|---|---|
| Algod | http://localhost:4001 |
| Indexer | http://localhost:8980 |
| KMD | http://localhost:4002 |
can run --network localnet
Testnet
Algorand TestNet via Nodely public APIs. Free to use, requires testnet ALGO from the dispenser.
| Service | URL |
|---|---|
| Algod | https://testnet-api.4160.nodely.dev |
| Indexer | https://testnet-idx.4160.nodely.dev |
can run --network testnet
Mainnet
Algorand MainNet via Nodely public APIs. Uses real ALGO.
| Service | URL |
|---|---|
| Algod | https://mainnet-api.4160.nodely.dev |
| Indexer | https://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:
| Variable | Description |
|---|---|
CAN_NETWORK | Network preset |
CAN_ALGOD_URL | Algod URL override |
CAN_ALGOD_TOKEN | Algod API token |
CAN_INDEXER_URL | Indexer URL override |
CAN_INDEXER_TOKEN | Indexer API token |
Commands Overview
The can CLI binary provides the following commands:
Wallet Management
| Command | Description |
|---|---|
setup | Interactive setup wizard (alias: init) |
import | Import wallet from mnemonic or hex seed |
change-password | Change keystore encryption password |
info | Show agent identity and wallet info |
Messaging
| Command | Description |
|---|---|
run | Start the agent and listen for messages |
send | Send an encrypted message |
inbox | Read cached messages |
Contacts & Groups
| Command | Description |
|---|---|
contacts | Manage PSK contacts (add, remove, list, export, import) |
groups | Manage group channels (create, members, export, import) |
Infrastructure & Integration
| Command | Description |
|---|---|
mcp | Start JSON-RPC 2.0 MCP server for Claude Code and Cursor |
fund | Fund wallet from localnet faucet or show instructions |
register | Register agent with the hub |
status | Health check (algod, indexer, hub, balance, plugins) |
plugin | Manage WASM plugins |
Global Flags
All commands accept:
| Flag | Default | Description |
|---|---|---|
--data-dir | ./data | Data 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
| Flag | Description |
|---|---|
--network <NETWORK> | Network preset: localnet, testnet, mainnet |
--generate | Generate 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.encto 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
| Flag | Description |
|---|---|
--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 setupfor 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:
- Polls for incoming AlgoChat messages on-chain
- Decrypts messages from known PSK contacts
- Forwards messages to the hub's A2A task endpoint (unless
--no-hub) - Polls the hub for responses
- Encrypts and sends replies back on-chain
Options
| Flag | Default | Description |
|---|---|---|
--network | localnet | Network preset |
--algod-url | from network | Override algod URL |
--algod-token | from network | Override algod token |
--indexer-url | from network | Override indexer URL |
--indexer-token | from network | Override indexer token |
--seed | from keystore | Agent seed (hex) |
--address | from keystore | Agent Algorand address |
--password | interactive | Keystore password |
--name | can | Agent name for discovery |
--hub-url | http://localhost:3578 | Hub URL |
--poll-interval | 5 | Seconds between polls |
--no-plugins | false | Disable plugin host |
--no-hub | false | P2P 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
| Flag | Description |
|---|---|
--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 |
--network | Network preset (default: localnet) |
--password | Keystore 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
- Resolves the recipient (contact name -> address, or validates raw address)
- Encrypts the message using the PSK shared with that contact
- Builds an Algorand transaction with the ciphertext in the note field
- Signs and submits the transaction
- 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
| Flag | Default | Description |
|---|---|---|
--from <NAME_OR_ADDRESS> | all | Filter by sender |
--limit <N> | 20 | Maximum 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.dbwhen the agent runs - The inbox only shows cached messages -- run
can runfirst 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]
| Flag | Description |
|---|---|
--name | Contact name (used for addressing in send and inbox) |
--address | Contact's Algorand address (58 chars) |
--psk | Pre-shared key: 64-char hex or 44-char base64 |
--force | Overwrite 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
| Option | Description |
|---|---|
--network NETWORK | Algorand network: localnet, testnet, or mainnet (default: localnet) |
--password PASSWORD | Keystore password for unlocking wallet |
--seed HEX | Wallet 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 addresscontacts_count— Number of saved contactsmessages_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 nameaddress— 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 namelimit(optional) — Maximum number of messages to return (default: 10)
Returns:
messages— Array of message objects:from— Sender's address or namebody— Message contenttimestamp— 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 addressmessage— Message body (plain text)
Returns:
transaction_id— Transaction ID on-chainstatus— 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
--passwordflag is passed on the command line, making it visible in process listings. For production use, consider:- Using
--seedwith a hex-encoded seed in an environment variable - Running the server with restricted file permissions
- Using a dedicated service account with limited wallet access
- Using
- 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
| Flag | Default | Description |
|---|---|---|
--network | localnet | Network preset |
--address | from keystore | Override agent address |
--kmd-url | http://localhost:4002 | KMD URL (localnet only) |
--kmd-token | auto | KMD API token (localnet only) |
--amount | 10000000 | Amount 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
| Flag | Default | Description |
|---|---|---|
--address | from keystore | Agent Algorand address |
--name | can | Agent display name |
--hub-url | http://localhost:3578 | Hub 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 setupfirst)
status
Check health of all connected services.
can status [OPTIONS]
Description
Runs connectivity checks against:
- Algod -- Algorand node (gets current round)
- Indexer -- Algorand indexer (health endpoint)
- Hub -- corvid-agent hub (health endpoint)
- Wallet -- address and balance
- Contacts -- count
- Messages -- cached message count and conversations
- Plugins -- plugin host status
Options
| Flag | Default | Description |
|---|---|---|
--network | localnet | Network preset |
--hub-url | http://localhost:3578 | Hub URL to check |
--password | interactive | Keystore 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
| Flag | Description |
|---|---|
--old-password | Current password (prompts if not provided) |
--new-password | New 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:
| Tier | Memory | Execution time |
|---|---|---|
| Trusted | 512 MiB | 60s |
| Verified | 128 MiB | 30s |
| Untrusted | 32 MiB | 10s |
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):
- CLI flags (
--network,--hub-url, etc.) - Environment variables (
CAN_NETWORK,CAN_PASSWORD, etc.) nano.tomlvalues- 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
- Both agents share a 32-byte PSK out-of-band
- The PSK is used to derive encryption keys via X25519 Diffie-Hellman
- Messages are encrypted with ChaCha20-Poly1305 (AEAD)
- The ciphertext is stored in the Algorand transaction note field
- 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
--forceflag oncan contacts addallows 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:
- Add every other member as a PSK contact with the group PSK
- 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 registerstill works but has no effect in P2P mode)
Plugins (WASM)
Extend agent capabilities with WebAssembly plugins.
Overview
The plugin system uses a sidecar architecture:
can runspawns thecorvid-plugin-hostbinary as a child process- The plugin host loads
.wasmfiles from the plugins directory cancommunicates 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:
| Tier | Memory | Timeout | Use case |
|---|---|---|---|
trusted | 512 MiB | 60s | First-party, fully audited plugins |
verified | 128 MiB | 30s | Third-party, code-reviewed plugins |
untrusted | 32 MiB | 10s | Unknown/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-wasip1target 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:OutputimplementsSerialize- 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:
| Tier | Memory | Timeout | Use |
|---|---|---|---|
trusted | 512 MiB | 60s | First-party plugins you control |
verified | 128 MiB | 30s | Code-reviewed third-party |
untrusted | 32 MiB | 10s | Unknown/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
- Keep tools fast — Prefer simple operations
- Cache results — Use
ctx.set()to avoid redundant computation - Batch requests — Make fewer HTTP calls with more data per call
- Minimize allocations — Pre-allocate buffers when possible
Publishing Your Plugin
- Create a repository on GitHub
- Add a README with usage instructions
- Include examples and tests
- Document the API for your tools
- Tag releases matching plugin versions
- 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
- Read the Plugins guide for running and managing plugins
- Check CONTRIBUTING.md for contribution guidelines
- Review the hello-world example for a minimal plugin
- Join the discussions for help
Security Considerations
Your plugin runs in a sandbox, but keep these in mind:
- Don't trust plugin input — Always validate parameters
- Handle errors gracefully — Return meaningful error messages
- Limit external calls — HTTP requests should have timeout and size limits
- Be careful with storage — Don't store secrets unencrypted
- 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:
- Transport — polls for inbound messages, sends outbound ones
- Plugins — receive events, return actions
- 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:
| Event | Triggered when | Typical use |
|---|---|---|
MessageReceived(msg) | A new message arrives from the transport | Reply, forward, log |
MessageSent { to, tx_id } | An outbound message is confirmed sent | Audit trail, receipts |
ContactAdded { address, name } | A contact is added | Welcome message |
ContactRemoved { address } | A contact is removed | Cleanup |
PluginLoaded { name } | A plugin finishes loading | Inter-plugin coordination |
PluginUnloaded { name } | A plugin is removed | Cleanup |
Timer { timestamp } | Periodic tick | Scheduled tasks |
Shutdown | Graceful shutdown starting | Save state, flush buffers |
Custom { kind, data } | Emitted by another plugin | Plugin-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:
- Receives incoming AlgoChat messages
- Forwards them to the hub's A2A task endpoint
- Polls for the AI-generated response
- 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) | |
|---|---|---|
| Language | Rust only | Any language targeting WASM |
| Sandboxing | None (runs in-process) | Full sandbox (memory, CPU, network) |
| Performance | Native speed, zero overhead | Small overhead from WASM runtime |
| Capabilities | Full Rust ecosystem | Limited to declared capabilities |
| Hot-reload | Restart required | Hot-reload with drain pattern |
| Use case | Trusted first-party plugins | Third-party/untrusted plugins |
| Testing | Standard cargo test | Build 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 — implement your own transport backend
- Examples — complete worked examples
- Plugin Development (WASM) — sandboxed WASM plugins
- Architecture Overview — how it all fits together
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
-
recv()should drain — return all pending messages and clear them. The runtime callsrecv()on a timer; returning the same messages twice will cause duplicate processing. -
send()should be idempotent-safe — if the runtime retries a failed send, your transport should handle it gracefully. -
Use
metadatafor transport-specific data — round numbers, transaction hashes, channel IDs, etc. Plugins can readmsg.metadatafor transport-specific context without the transport leaking into the core API. -
Handle errors gracefully — the runtime logs transport errors and continues. Don't panic in
recv()orsend().
Next Steps
- Nano Runtime — the event-driven plugin system
- Examples — complete worked examples
- Architecture Overview — system design
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:
- Picks up AlgoChat messages from the transport
- Forwards them to
POST {hub_url}/a2a/tasks/send - Polls
GET {hub_url}/a2a/tasks/{id}for the AI response - 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:
| Tool | Description |
|---|---|
send_message | Send an encrypted AlgoChat message to a contact |
list_contacts | List all PSK contacts |
get_inbox | View received messages (with optional filters) |
check_balance | Check the agent's ALGO balance |
agent_info | Get 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
- Getting Started — first-time setup
- Nano Runtime — build native plugins
- Custom Transport — implement your own transport
- Plugin Development (WASM) — sandboxed WASM plugins
- MCP Integration — detailed MCP setup
- Connecting to a Hub — AI-powered responses
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
caninstalled (Rust binary):cargo install --git https://github.com/CorvidLabs/corvid-agent-nano --bin can- A corvid-agent-nano wallet with
can setupalready 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 startis 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
| Crate | Purpose |
|---|---|
algochat | AlgoChat protocol (encryption, key exchange, message format) |
clap | CLI argument parsing with derive macros |
tokio | Async runtime |
rusqlite | SQLite for contacts, groups, messages |
argon2 | Password hashing (Argon2id) |
chacha20poly1305 | Authenticated encryption |
ed25519-dalek | EdDSA signing for Algorand transactions |
wasmtime | WebAssembly plugin runtime |
dialoguer | Interactive terminal prompts |
colored | Terminal 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
zeroizecrate - 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:
| Tier | Memory | Timeout | CPU limit | Use case |
|---|---|---|---|---|
trusted | 512 MiB | 60s | Unlimited | First-party, audited, critical functionality |
verified | 128 MiB | 30s | 100% | Third-party code-reviewed plugins |
untrusted | 32 MiB | 10s | 50% | 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:
- Messaging — Send/receive encrypted messages
- Storage — Key-value store (plugin-isolated)
- Algorand — Query chain state, build transactions
- 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)
- Example:
- 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
| Algorithm | Purpose | Why this choice |
|---|---|---|
| Ed25519 | Wallet signing | Fast, secure, no recovery parameter issues |
| X25519 | Key exchange | Post-quantum resistant forward secrecy |
| ChaCha20-Poly1305 | Message encryption | Fast, constant-time, AEAD (authenticated encryption) |
| Argon2id | Password hashing | Memory-hard, GPU/ASIC resistant |
| SHA-256 | Hashing (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 auditchecks for known vulnerabilities - Static analysis: CodeQL scans for common security issues
- Spec validation:
specsyncensures code matches security specifications
Reporting vulnerabilities
If you discover a security issue:
- Do not open a public GitHub issue
- 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
- Keep your recovery phrase safe — Store offline, not in files/email
- Use a strong password — 12+ characters, mix of letters/numbers/symbols
- Use HTTPS for hub communication — Especially on testnet/mainnet
- Audit plugins before installing — Review the WASM source or trust the author
- Run your own Algorand node — For privacy and reliability
- Rotate PSKs periodically — For long-lived agent relationships
- 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
| File | Format | Purpose |
|---|---|---|
keystore.enc | JSON | Encrypted wallet (Argon2id + ChaCha20-Poly1305) |
contacts.db | SQLite | PSK contacts |
groups.db | SQLite | Group channels and members |
keys.db | SQLite | AlgoChat key storage (DH session keys) |
messages.db | SQLite | Message cache (inbox) |
plugins/ | Directory | WASM 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
| Variable | Description | Default |
|---|---|---|
CAN_NETWORK | Network preset: localnet, testnet, mainnet | localnet |
CAN_ALGOD_URL | Override algod URL | from network |
CAN_ALGOD_TOKEN | Override algod API token | from network |
CAN_INDEXER_URL | Override indexer URL | from network |
CAN_INDEXER_TOKEN | Override indexer API token | from network |
Identity
| Variable | Description | Default |
|---|---|---|
CAN_SEED | Agent seed (hex-encoded 32 bytes) | from keystore |
CAN_ADDRESS | Agent Algorand address | from keystore |
CAN_PASSWORD | Keystore password | interactive prompt |
Logging
| Variable | Description | Default |
|---|---|---|
RUST_LOG | Log level filter | info |
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:
- Both agents are on the same network (localnet/testnet/mainnet)
- Both agents have each other as PSK contacts with the same key
- The sending agent has sufficient ALGO balance for transactions
- Run
can statusto 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-urlflag 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 phrasecan 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:
-
Same network? Both agents must be on localnet/testnet/mainnet
can info # Check your network -
Mutual contacts? Both agents must have each other as PSK contacts
can contacts list -
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 -
Running? The agent must be running to receive messages
can run -
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 runon the hub machine - Is the URL correct? Check
--hub-urlflag (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?
- Get the hub's Algorand address from its administrator
- 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"}' - Add the hub as a contact on your side:
can contacts add --name hub \ --address HUB_ADDRESS \ --psk PSK_FROM_STEP_2 - 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:
- It decrypts the message
- Sends it to the hub's A2A endpoint
- Waits for the hub to process (AI reasoning, tool calls, etc.)
- Encrypts the hub's response
- 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:
| Tier | Memory | Timeout | Use |
|---|---|---|---|
trusted | 512 MiB | 60s | Your own plugins |
verified | 128 MiB | 30s | Code-reviewed plugins |
untrusted | 32 MiB | 10s | Unknown 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:
-
Get recovery phrase from first machine:
# It's shown during setup — write it down if you didn't -
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/*.dband re-run - Missing algod/indexer — Start localnet:
algokit localnet start - Wrong network — Check
--networkflag
"Decryption failed — wrong password?"
Your password is incorrect. Options:
- Try the correct password
- 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:
- Fork the repo
- Create a branch (
feature/name) - Make changes and test
- Open a PR
- Address review feedback
Getting Help
- Documentation — You're reading it!
- Issues — GitHub Issues for bugs
- Discussions — GitHub 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!