Docs

Agents

spaceturtle

The connector, by hand

Run a node

A node running on your machine and answering GET /ilp, in one command or by hand.

The connector is one static binary that reads one TOML file. Three ways to get it running: init writes a devnet node for you, Docker runs the one you write, and source is for changing the connector rather than running it.

The fastest start: init

One command writes a runnable devnet node into a directory: a config with one priced route, the Base Sepolia settlement block, an operator surface so you can see what you were paid, fresh keys owned by you, and a compose.yml that runs the connector and a payment-oblivious echo app as your own uid. It touches no chain and overwrites nothing.

The shelldevnet, free
IMG=ghcr.io/toon-protocol/connector:rust-main   # or a dated release handle that carries init
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/w" -w /w "$IMG" init --devnet --dir node --image "$IMG"
cd node && docker compose up -d
curl -s http://127.0.0.1:3000/ilp | jq .routes

init prints what it wrote, the node's settlement address, and the three toon-client commands that pay it. Only --devnet exists: a mainnet node settles real money in a token you choose, and every value in that block is yours to read off the chain (Get paid). Flags: --dir (default .), --address (default g.devnet.<six hex digits>, also the route's prefix), --image (what the compose file runs; pin a release handle for a node you keep). A directory that already holds a config is refused by name, because rotating the signer key would change the node's identity under every peer.

The rest of this page explains each file init wrote, so you can change it.

  1. 01Pull itPin a release handle and generate the node's identity key.
  2. 02Price a routePoint a prefix at your app with a price in the token's smallest unit.
  3. 03Start itdocker compose up -d, then ask the node what it is with GET /ilp.

With Docker

Pin a release handle, rust-<handle>, a UTC date and that day's ordinal. Handles are immutable and never moved. rust-sha-<short-sha> pins an exact commit and is equally immutable. rust-main floats on every green build of main: fine for a scratch trial, never for a deployment.

The image runs as uid 10001, and it reads the key files you mount. A key you create as yourself with mode 600 is unreadable to that uid, and the container restarts forever with failed to read signer key_file … Permission denied. So hand the key to that uid when you make it, and do the same for every settlement key later:

The shellconnector 2026.09.29.1
docker pull ghcr.io/toon-protocol/connector:rust-2026.09.29.1
mkdir -p node/config node/data && cd node
openssl rand -hex 32 > data/signer.key
chmod 600 data/signer.key && sudo chown 10001 data/signer.key   # the image reads it as uid 10001
The published image is linux/amd64 only. On Apple Silicon or any other arm64 host, pull with --platform linux/amd64 and add platform: linux/amd64 next to image: in the compose file.

The image creates /app/state owned by uid 10001, so a fresh named volume inherits the ownership. A host bind mount arrives root-owned and the connector refuses to start.

compose.yml
services:
  connector:
    image: ghcr.io/toon-protocol/connector:rust-2026.09.29.1
    command: ['/app/config/connector.toml']
    volumes:
      - ./config/connector.toml:/app/config/connector.toml:ro
      - ./data:/app/data:ro
      # A NAMED volume, not a bind mount.
      - connector-state:/app/state
    ports:
      # Loopback to begin with. Put a TLS-terminating reverse proxy
      # in front of it before it faces the world.
      - '127.0.0.1:3000:3000'
    restart: unless-stopped
    healthcheck:
      # Free and unauthenticated. Answering means the config loaded,
      # every settlement backend connected, and the router is serving.
      test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3000/ilp/identity']
      interval: 10s
      timeout: 3s
      retries: 5

  quotes:
    image: your-quotes-app:latest
  search:
    image: your-search-app:latest

volumes:
  connector-state:
docker compose up -d

The quotes and search services stand for your own apps. If you have none yet, delete them and their routes, or keep one route and point it at any small HTTP container: a route whose handler_url answers nothing is a route a caller pays for and gets a reject from.

From source

For changing the connector rather than running it. Rust stable:

git clone https://github.com/toon-protocol/connector.git
cd connector && cargo build --workspace
./target/debug/connector path/to/connector.toml

Paths in the config are then yours, not the image's: state_dir = "./state", key_file = "./signer.key". The repository's CONTRIBUTING.md has the test gate and the chain binaries it needs.

The config

price is a whole number of the smallest unit of the token you settle in, the way a card terminal counts in cents, never in dollars. How many units make one token is the token's decimals. USDC has 6, so 1,000,000 units are one USDC:

priceIn USDCWhich is
10000.001a tenth of a cent
1000000.10ten cents
10000001.00one dollar
connector.toml
client_edge_addr = "0.0.0.0:3000"
state_dir        = "/app/state"

[signer]
key_file = "/app/data/signer.key"   # 32 raw bytes, or 64 hex characters

# One route per thing you serve. Longest matching prefix wins.
[[routes]]
prefix      = "g.example.quotes"
handler_url = "http://quotes:8080/"
price       = 1000                       # 0.001 USDC per request

[[routes]]
prefix      = "g.example.search"
handler_url = "http://search:8080/"
price       = 2500                       # 0.0025 USDC

# Same app, deeper prefix, different price. This wins over g.example.search
# for g.example.search.bulk because it matches more labels.
[[routes]]
prefix      = "g.example.search.bulk"
handler_url = "http://search:8080/bulk/"
price       = 10000                      # 0.01 USDC, a cent

# Price by size when your own costs grow with the payload: storage, uploads,
# anything you pay an upstream by the byte for. The charge for one packet is
# base + per_kib × ceil(payload_size / 1024). Both figures are published.
[[routes]]
prefix      = "g.example.store"
handler_url = "http://store:8080/"
price       = { base = 1000, per_kib = 30 }   # 0.001 USDC + 0.00003 per KiB

Then ask the node what it is:

curl http://localhost:3000/ilp

That free, unauthenticated GET returns the node's self-description: its addresses, endpoints, identity key and settlement facts. A connector answers; it never announces. It is also the whole of what another operator needs to peer with you, once [node] and peer_expose are set. This minimal config's own self-description has no endpoints and nothing to dial.

This node can be curled but not yet paid, or even described by the client. Until it has a settlement block, toon-client describe and price refuse it with "publishes no settlement chains at all", because a client cannot pay a node that does not say how. Get paid is the next page; its devnet block is eight lines and costs nothing.

Two more things worth setting before anyone calls you. A [node] table with addresses = ["g.your.node"] gives the node an ILP address of its own; if that address is also the prefix of a priced route, a caller can send to you without naming a destination, as the devnet store allows. And an [operator] table is the only way to see that you were paid: at the default log level the node writes nothing when it admits a channel or accepts a voucher, so without GET /claims or the dashboard a working node looks idle.

One TOML file, read once, immutable for the process lifetime. There is no environment-variable layer; the only variable read is RUST_LOG. An unknown key is a hard load failure and a removed key is refused by name, so a stale config says so at boot instead of quietly doing nothing.

Every key is a path, never a value. No inline keys, no mnemonic, nowhere to smuggle one through.

state_dir is where this node records which claims it has already been paid. In a container it must be a mounted volume. A watermark that dies with the container hands every payer their spent claims back as free service.

Before it faces the world

Put a TLS-terminating reverse proxy in front of the client edge. A TOON node refuses plaintext peers, and every deployed config should publish https:// and wss:// endpoints only. The node has no second port: the client edge, the peer listeners and the operator surface all merge onto client_edge_addr.

A route with a price is only half of being paid. The next two pages are the other half: what your app sees, and the settlement block that lets anyone actually pay you. If you would rather have no domain, no certificate and no open port at all, Hidden services puts the node at a .anyone address instead.

Written from connector/docs/guide/run-a-node.md. The release handle is the connector's latest GitHub release.