> For the complete documentation index, see [llms.txt](https://docs.talus.network/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.talus.network/talus-docs-v2.1.0/guides/tool-development/build-offchain-tool.md).

# Build an Off-Chain Tool

{% hint style="info" %}
**Audience:** Nexus adopters and integrators.

**Goal:** Build, run, register, and verify an HTTP Tool artifact that returns schema-valid workflow output.
{% endhint %}

An off-chain tool is an HTTP service that the Nexus runtime invokes during a workflow execution. It is where you wrap an external API, run an LLM, or do any computation the chain cannot perform directly. By the end of this guide you will have a working tool that returns a crypto spot price, and you will understand how a tool is scaffolded, run, validated, registered, invoked, and versioned.

<figure><picture><source srcset="/files/ZYlwgbrmCJBKyFL4Mns7" media="(prefers-color-scheme: dark)"><img src="https://3395888576-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLPrUNT846cHDCVQcRD3f%2Fuploads%2Fgit-blob-7fae07794e10cf9ab5f966230dac9974e8db0e17%2Fd50-build-types-light.svg?alt=media" alt="Build a Tool with canonical Rust input/output types"></picture><figcaption><p>Phase 1: Build a Tool with canonical Rust input/output types.</p></figcaption></figure>

<figure><picture><source srcset="/files/Ju1qXBFQIWGGXE5iTHPB" media="(prefers-color-scheme: dark)"><img src="https://3395888576-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLPrUNT846cHDCVQcRD3f%2Fuploads%2Fgit-blob-25082657c25e8cf0c463dc063562713a17ef2756%2Fd50-runtime-endpoints-light.svg?alt=media" alt="Expose health, metadata, and signed invoke endpoints"></picture><figcaption><p>Phase 2: Expose health, metadata, and signed invoke endpoints.</p></figcaption></figure>

<figure><picture><source srcset="/files/vyKXbGpg9vwEfjeFPs9V" media="(prefers-color-scheme: dark)"><img src="https://3395888576-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FLPrUNT846cHDCVQcRD3f%2Fuploads%2Fgit-blob-79900d75ae828d69a2f93c95e65828612ec43fc9%2Fd50-leader-execution-light.svg?alt=media" alt="Leader invokes the Tool and verifies downstream DAG state"></picture><figcaption><p>Phase 3: Leader invokes the Tool and verifies downstream DAG state.</p></figcaption></figure>

The architecture figure separates the Tool's HTTP boundary from the on-chain workflow: the Toolkit owns schema and transport encoding, the Leader sends one resolved vertex request, and only the canonical result/evidence becomes durable workflow state. Keep provider credentials inside the off-chain runtime; the chain stores the registered contract and submitted evidence, not the secret.

For a tool that mutates on-chain state or moves assets, build an [on-chain tool](/talus-docs-v2.1.0/guides/tool-development/build-onchain-tool.md) instead. To make the verification path explicit, see [Verify an off-chain tool result](/talus-docs-v2.1.0/guides/tool-development/verify-offchain-tool-result.md).

### Prerequisites

* A Bash/POSIX-compatible shell plus `jq`, `curl`, and coreutils-compatible `sha256sum`, `stat`, and `mktemp` for the documented checks. Node.js 18 or later is needed only for the hosted Nexus API tutorial or its React example.
* Verify the shell tools with the [Developer Setup](/talus-docs-v2.1.0/guides/getting-started/setup.md) check before using the commands below.
* A working Rust toolchain and the Nexus CLI on your `PATH`.
* A configured Nexus environment pointing at your target network (localnet, testnet, or mainnet). Confirm it with `nexus conf`; the full CLI surface is in the [CLI reference](/talus-docs-v2.1.0/reference/cli/conf.md).
* Complete [Developer Setup's protected-signer gate](/talus-docs-v2.1.0/guides/getting-started/setup.md) before using this registration/mutation path. Without provider-approved protected import and `nexus conf set --help` verification, stop at query-only SDK/Explorer/API inspection and do not run Nexus mutation commands.
* After that gate is complete, configure a signer SUI address balance for default CLI transaction/reserve funding; verify with `nexus gas balance` and fund it with `nexus gas deposit`. Supply an owned SUI gas coin only when the command explicitly accepts a gas-coin flag.
* An owned `Coin<US>` for Tool collateral before registration. The required amount is network/deployment-specific; obtain it through the supported wallet/operator path and inspect the owned coin plus the Tool registration requirement before signing. The [priority-fee vault guide](/talus-docs-v2.1.0/guides/tokenomics/use-priority-fee-vault.md) documents the owned-coin/readback boundary; do not invent an acquisition command or substitute address-balance SUI.
* Network access for the first dependency acquisition, or a complete Cargo registry/Git cache that can satisfy the graph after this project's manifest and lockfile exist.

{% hint style="info" %}
The pinned on-chain registration and CLI paths do not enforce an address allowlist. A hosted network may impose a separate gateway or operator policy; obtain that policy from the network operator and do not confuse it with the Move/CLI checks documented here.
{% endhint %}

#### How an off-chain tool works

```mermaid
sequenceDiagram
  participant Leader
  participant Tool as Toolkit HTTP service
  participant Provider as External provider
  participant Workflow as Nexus workflow
  Leader->>Tool: GET /health and GET /meta
  Leader->>Tool: POST /invoke with ordered input and v3 headers
  Tool->>Provider: Call provider or local computation
  Provider-->>Tool: Provider response
  Tool-->>Leader: Canonical output variant and ports
  Leader->>Workflow: Submit result with required evidence
  alt Evidence accepted
    Workflow-->>Leader: Commit and advance downstream edges
  else Evidence rejected or request fails
    Workflow-->>Leader: Record failure, retry, or refund boundary
  end
```

The sequence is the end-to-end contract behind the later code: readiness and metadata are checked before invocation, provider work stays off chain, and the workflow—not the HTTP response alone—decides whether the result advances the DAG or enters a failure/recovery path.

Your tool implements the `NexusTool` trait from the Nexus toolkit. The toolkit runtime turns that implementation into an HTTP server exposing three endpoints:

* `GET /health` — liveness and readiness of the tool and its dependencies.
* `GET /meta` — the tool's metadata: FQN, input schema, output schema, timeout.
* `POST /invoke` — runs the tool's logic against one input and returns one output.

The runtime calls these endpoints; you never call `/invoke` by hand in production. The state flow at a tool vertex is:

1. The leader gathers the vertex's resolved input ports in the Tool schema's declared order and computes their canonical commitment.
2. It sends `POST /invoke`; when a Leader signing key is configured, it signs that canonical input hash and includes the execution-derived nonce in v3 headers.
3. The Toolkit checks the signed canonical hash when signed HTTP is required, invokes your Rust `Output`, and encodes the selected variant and named ports as exact ordered BCS.
4. The leader decodes that canonical result and submits it with the evidence required by the vertex, where its ports become values for downstream edges.

So the *variant you return* selects which downstream edges fire, and the *ports in that variant* become the values flowing along those edges.

#### The FQN and metadata model

Every tool is identified by a fully qualified name (FQN) of the shape `domain.name@version`, for example `xyz.taluslabs.exchanges.coinbase.spot-price@1`. The rules the parser enforces:

* Splitting by `.` yields at least three parts; the first parts joined form the domain, and the final part before `@` is the name.
* Each part matches `[a-z][a-z0-9_-]+` — lowercase, at least two characters, and never starting with a digit, `-`, or `_`.
* The version is a positive integer; `0` is rejected.

The `fqn!` macro validates the string at compile time, so a malformed FQN fails to build rather than failing at registration.

Everything the network needs to discover and call your tool lives in its metadata, served from `GET /meta`:

| Field           | Meaning                                                              |
| --------------- | -------------------------------------------------------------------- |
| `fqn`           | The tool's fully qualified name.                                     |
| `url`           | The base URL the runtime invokes.                                    |
| `description`   | Human-readable summary.                                              |
| `timeout`       | How long the runtime waits for `/invoke` (milliseconds on the wire). |
| `input_schema`  | JSON Schema (draft 2020-12) generated from your `Input`.             |
| `output_schema` | JSON Schema generated from your `Output` enum.                       |

The toolkit derives both schemas from your Rust types — you never write JSON Schema by hand.

#### Step 1 — Scaffold the project

Create a new Rust tool project with the CLI:

```bash
nexus tool new --name spot-price --description "Return a spot price for one asset pair" --template rust --target ./
cd spot-price
```

This generates a buildable project: a `Cargo.toml` with the default Nexus dependencies and a `src/main.rs` with a template `NexusTool` implementation and a `bootstrap!` call. Add your own README when the Tool needs operator or deployment instructions.

Our example calls an external HTTP API, so add the two dependencies the template does not ship with under its existing `[dependencies]` table; do not create a second table. Edit `Cargo.toml`:

```toml
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
```

The `v2.1.0` Rust scaffold source template emits `nexus-sdk = "v2.1.0"` and `nexus-toolkit = "v2.1.0"`. Replace those generated version requirements with the exact registry dependencies below before creating or reviewing the lockfile so the guide, SDK, and Toolkit use one implementation.

```toml
nexus-sdk = "=2.1.0"
nexus-toolkit = "=2.1.0"
```

**Step 1a — Generate and acquire the locked dependency graph**

The scaffold's `nexus-sdk`, `nexus-toolkit`, and external HTTP dependencies resolve from crates.io. Create or review `Cargo.lock` only after the scaffold and every dependency edit are complete: keep a supplied, reviewed lockfile; otherwise run `cargo generate-lockfile` from the project directory.

```bash
# Run only when the scaffold did not supply a reviewed Cargo.lock.
cargo generate-lockfile
cargo fetch --locked
cargo check --locked
```

`--locked` prevents Cargo from changing `Cargo.lock`; it does not make dependency acquisition network-free. After a successful locked fetch, a disconnected environment can reuse the same cache with:

```bash
CARGO_NET_OFFLINE=true cargo check --locked
CARGO_NET_OFFLINE=true cargo test --locked
```

Offline mode works only when the exact registry packages and checksums required by this project's `Cargo.lock` are already cached. If lock generation or `cargo fetch --locked` fails because a registry, proxy, or credential is unavailable, fix that network/cache boundary or provide a complete matching cache/vendor directory, rerun the locked fetch, and only then use offline checks; do not report an offline build as successful after Cargo says a dependency is missing.

After the locked check, run the complete published five-test module from Step 5 with `cargo test --locked` from the consumer Tool directory. Toolkit integration and TLS tests are separate: a consumer manifest cannot select a dependency package with `-p nexus-toolkit`, so clone the pinned public SDK repository and run those tests from that source directory.

**Step 1b — Verify the v2.1.0 SDK source**

The consumer Tool and the SDK source are two different Cargo contexts. The consumer test proves the five Tool tests against the exact lockfile; the public SDK source proves Toolkit behavior and the TLS stalled-handshake regression. Ensure its registry/Git dependencies can be fetched, and run these commands from the selected public source directory:

```bash
NEXUS_SDK_DIR="${NEXUS_SDK_DIR:-sdk-source}"
git clone https://github.com/Talus-Network/nexus-sdk.git "$NEXUS_SDK_DIR"
git -C "$NEXUS_SDK_DIR" checkout 45d397aafcfbeeeaf5032d5fb9fa5d99b3f36205
test -f "$NEXUS_SDK_DIR/Cargo.toml"
cd "$NEXUS_SDK_DIR"
cargo test --locked -p nexus-toolkit --test integration
cargo test --locked -p nexus-toolkit accepts_https_while_another_handshake_is_stalled
```

The public SDK source needs the normal Rust toolchain, Cargo registry/Git access for the first fetch, and the selected revision's lockfile/cache; use a writable `CARGO_HOME` when the default cache is read-only. Do not run the `-p nexus-toolkit` commands from the generated consumer Tool directory.

#### Step 2 — Design the input and output schemas

The tool's input and output types *are* its interface: each `Input` field becomes an input port, and each `Output` enum variant becomes an output variant whose fields become output ports.

```rust
/// Input ports for the spot-price tool.
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct Input {
    /// The trading pair to price, e.g. `BTC-USD` or `ETH-EUR`.
    currency_pair: String,
}

/// Output variants for the spot-price tool.
#[derive(Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum Output {
    /// The spot price was fetched successfully.
    Ok {
        /// The price amount as a string, e.g. `"61234.56"`.
        amount: String,
        /// The base currency, e.g. `BTC`.
        base: String,
        /// The quote currency, e.g. `USD`.
        currency: String,
    },
    /// The price could not be fetched.
    Err {
        /// A human-readable description of what went wrong.
        reason: String,
    },
}
```

Conventions worth calling out:

* `Input` derives `Deserialize` and `JsonSchema`. `#[serde(deny_unknown_fields)]` rejects malformed inputs early.
* `Output` must be an `enum` so the generated schema has a top-level `oneOf`; the toolkit runtime enforces this. Derive `Serialize` and `JsonSchema`, and use `#[serde(rename_all = "snake_case")]` so variants serialize as `ok`/`err`.
* Name error variants with an `err` prefix. Nexus treats `err`-prefixed variants specially and always forwards their ports on-chain, so downstream vertices can branch on the failure.
* Keep ports flat and stable — return `err` rather than an `ok` with optional fields when you cannot produce the requested data.

#### Step 3 — Implement the `NexusTool` trait

The interesting method is `invoke`: it performs the outbound request and maps both success and failure onto output variants. Keep the production Coinbase URL behind a constructor seam so tests can inject a deterministic local upstream without changing the Tool schema or FQN.

```rust
const COINBASE_BASE_URL: &str = "https://api.coinbase.com";
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(3);

struct SpotPrice {
    client: reqwest::Client,
    base_url: String,
}

impl SpotPrice {
    fn with_base_url(base_url: impl Into<String>) -> AnyResult<Self> {
        let client = reqwest::Client::builder()
            .timeout(UPSTREAM_TIMEOUT)
            .build()?;

        Ok(Self {
            client,
            base_url: base_url.into().trim_end_matches('/').to_string(),
        })
    }

    fn price_url(&self, currency_pair: &str) -> String {
        format!("{}/v2/prices/{currency_pair}/spot", self.base_url)
    }
}

impl NexusTool for SpotPrice {
    fn description() -> &'static str {
        "Returns the current Coinbase spot price for a requested currency pair."
    }

    type Input = Input;
    type Output = Output;

    async fn new() -> Self {
        Self::with_base_url(COINBASE_BASE_URL)
            .expect("the bounded Coinbase HTTP client should build")
    }

    fn fqn() -> ToolFqn {
        // The fully qualified name uniquely identifies this tool: `domain.name@version`.
        fqn!("xyz.taluslabs.exchanges.coinbase.spot-price@1")
    }

    async fn health(&self) -> AnyResult<StatusCode> {
        let response = match self.client.get(self.price_url("BTC-USD")).send().await {
            Ok(response) => response,
            Err(_) => return Ok(StatusCode::SERVICE_UNAVAILABLE),
        };
        Ok(if response.status().is_success() {
            StatusCode::OK
        } else {
            StatusCode::SERVICE_UNAVAILABLE
        })
    }

    async fn invoke(&self, input: Self::Input) -> Self::Output {
        let url = self.price_url(&input.currency_pair);

        let response = match self.client.get(&url).send().await {
            Ok(response) => response,
            Err(e) => {
                return Output::Err {
                    reason: e.to_string(),
                }
            }
        };

        if !response.status().is_success() {
            return Output::Err {
                reason: format!(
                    "upstream returned HTTP {} for pair `{}`",
                    response.status().as_u16(),
                    input.currency_pair
                ),
            };
        }

        let body: serde_json::Value = match response.json().await {
            Ok(body) => body,
            Err(e) => {
                return Output::Err {
                    reason: e.to_string(),
                }
            }
        };

        match (
            body["data"]["amount"].as_str(),
            body["data"]["base"].as_str(),
            body["data"]["currency"].as_str(),
        ) {
            (Some(amount), Some(base), Some(currency)) => Output::Ok {
                amount: amount.to_string(),
                base: base.to_string(),
                currency: currency.to_string(),
            },
            _ => Output::Err {
                reason: format!("unexpected response for pair `{}`", input.currency_pair),
            },
        }
    }
}
```

`with_base_url` is the deterministic transport seam: production `new()` always defaults to Coinbase, while tests point the same request construction and response parser at a local listener. The client-wide three-second timeout bounds both `/health` and `/invoke`. Here `/health` is dependency readiness, not process-only liveness: it probes Coinbase's `BTC-USD` spot-price route and returns `503` for a non-success response or a transport error through the Toolkit's health error path.

{% hint style="info" %}
`invoke` returns `Self::Output`, never a `Result`. Errors are valid *output variants* in Nexus, so the tool catches every failure and returns it as an `err` variant rather than panicking or bubbling up. Any variant whose name starts with `err` has all its ports forwarded on-chain automatically.
{% endhint %}

The trait carries defaults you rarely override: `timeout()` defaults to 10 seconds and `path()` to the root URL. Implement `description()` with useful non-empty text: the SDK rejects an empty or whitespace-only description at registration. The optional `authorize` hook runs after signed HTTP authentication and lets you apply Tool-side policy such as caller allowlisting or rate limiting. Returning an error yields a local JSON `403`; local HTTP errors are not signed verifier evidence.

#### Step 4 — Bootstrap the server

Start the HTTP server with the `bootstrap!` macro:

```rust
#[tokio::main]
async fn main() {
    bootstrap!(SpotPrice);
}
```

`bootstrap!(SpotPrice)` serves the tool on `127.0.0.1:8080`. The macro is flexible:

```rust
// Bind to a specific address.
bootstrap!(([0, 0, 0, 0], 8081), SpotPrice);

// Serve several tools from one server (each must have a unique `path`).
bootstrap!(([127, 0, 0, 1], 8080), [SpotPrice, AnotherTool]);
```

If you call `bootstrap!(SpotPrice)` without an address, set the bind address at runtime via the `BIND_ADDR` environment variable.

The top of the generated `src/main.rs` already imports everything you need:

```rust
use {
    nexus_sdk::*,
    nexus_toolkit::*,
    schemars::JsonSchema,
    serde::{Deserialize, Serialize},
    std::time::Duration,
};
```

#### Step 5 — Test the tool

The scaffold already includes `tokio = { version = "1", features = ["full"] }`, so no unverified mock crate or extra dev dependency is needed. Add this complete module at the end of `src/main.rs`. It starts a one-request local HTTP server, asserts the exact path, and covers readiness, success, a bad-pair error response, and an unreachable endpoint without calling Coinbase:

```rust
#[cfg(test)]
mod tests {
    use {
        super::*,
        tokio::{
            io::{AsyncReadExt, AsyncWriteExt},
            net::TcpListener,
            task::JoinHandle,
        },
    };

    async fn spawn_mock(
        expected_path: &'static str,
        status: &'static str,
        body: &'static str,
    ) -> (String, JoinHandle<()>) {
        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
        let address = listener.local_addr().unwrap();
        let server = tokio::spawn(async move {
            let (mut socket, _) = listener.accept().await.unwrap();
            let mut request = [0_u8; 4096];
            let read = socket.read(&mut request).await.unwrap();
            let request = String::from_utf8_lossy(&request[..read]);
            assert!(request.starts_with(&format!("GET {expected_path} HTTP/1.1\r\n")));

            let response = format!(
                "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
                body.len()
            );
            socket.write_all(response.as_bytes()).await.unwrap();
        });

        (format!("http://{address}"), server)
    }

    #[tokio::test]
    async fn health_probes_the_injected_upstream() {
        let (base_url, server) = spawn_mock(
            "/v2/prices/BTC-USD/spot",
            "200 OK",
            r#"{"data":{"amount":"61234.56","base":"BTC","currency":"USD"}}"#,
        )
        .await;
        let tool = SpotPrice::with_base_url(base_url).unwrap();

        assert_eq!(tool.health().await.unwrap(), StatusCode::OK);
        server.await.unwrap();
    }

    #[tokio::test]
    async fn health_reports_unavailable_for_an_unreachable_upstream() {
        let tool = SpotPrice::with_base_url("http://127.0.0.1:0").unwrap();

        assert_eq!(
            tool.health().await.unwrap(),
            StatusCode::SERVICE_UNAVAILABLE
        );
    }

    #[tokio::test]
    async fn invoke_returns_a_spot_price() {
        let (base_url, server) = spawn_mock(
            "/v2/prices/BTC-USD/spot",
            "200 OK",
            r#"{"data":{"amount":"61234.56","base":"BTC","currency":"USD"}}"#,
        )
        .await;
        let tool = SpotPrice::with_base_url(base_url).unwrap();

        match tool
            .invoke(Input {
                currency_pair: "BTC-USD".to_string(),
            })
            .await
        {
            Output::Ok {
                amount,
                base,
                currency,
            } => {
                assert_eq!(amount, "61234.56");
                assert_eq!(base, "BTC");
                assert_eq!(currency, "USD");
            }
            Output::Err { reason } => panic!("unexpected error: {reason}"),
        }
        server.await.unwrap();
    }

    #[tokio::test]
    async fn invoke_returns_err_for_a_bad_pair_response() {
        let (base_url, server) = spawn_mock(
            "/v2/prices/BAD-PAIR/spot",
            "404 Not Found",
            r#"{"error":"unknown pair"}"#,
        )
        .await;
        let tool = SpotPrice::with_base_url(base_url).unwrap();

        match tool
            .invoke(Input {
                currency_pair: "BAD-PAIR".to_string(),
            })
            .await
        {
            Output::Err { reason } => {
                assert_eq!(reason, "upstream returned HTTP 404 for pair `BAD-PAIR`")
            }
            Output::Ok { .. } => panic!("bad pair unexpectedly succeeded"),
        }
        server.await.unwrap();
    }

    #[tokio::test]
    async fn invoke_returns_err_for_an_unreachable_endpoint() {
        let tool = SpotPrice::with_base_url("http://127.0.0.1:0").unwrap();

        match tool
            .invoke(Input {
                currency_pair: "BTC-USD".to_string(),
            })
            .await
        {
            Output::Err { reason } => assert!(!reason.is_empty()),
            Output::Ok { .. } => panic!("unreachable endpoint unexpectedly succeeded"),
        }
    }
}
```

Run the tests before starting the server:

```bash
cargo test --locked
```

#### Step 6 — Run and validate

Build and start the tool:

```bash
cargo run
```

With the server running, validate it with the CLI. Validation checks that `GET /health` returns `200`, that `GET /meta` is valid JSON, and that the output schema has the required top-level `oneOf`:

```bash
nexus tool validate offchain --url http://localhost:8080
```

#### Step 7 — Keep localhost local

Do not register `http://localhost:8080`. Local HTTP is only for development validation; the Nexus leader transport accepts HTTPS Tool URLs. Configure signed HTTP, deploy the Tool behind its final HTTPS endpoint, and register that reachable URL in Step 9.

#### Step 8 — Configure signed HTTP v3

Signed HTTP v3 authenticates canonical Tool commitments; it does not replace HTTPS. The Leader signs only the schema-ordered canonical input hash. The Tool signs the response-domain message containing that Leader signature, the deterministic execution/walk/runtime-vertex/iteration nonce, and the domain-separated SHA-256 of canonical response BCS.

The Toolkit has only `Disabled` and `Required` modes. If `NEXUS_TOOLKIT_CONFIG_PATH` is unset, or `signed_http` is absent/disabled, the runtime accepts unsigned requests and produces no Tool signature. `Required` needs an allowed-Leader source and a nonempty Tool configuration. Add `response_signing_key` only for a Tool that uses the RegisteredKey/signed-result path; request authentication can operate without response-signing material.

First export the active Leader keys that this Tool may trust:

```bash
nexus tool auth export-allowed-leaders --all --out ./allowed-leaders.json
```

Use repeated `--leader <LEADER_CAP_ID>` instead of `--all` to restrict callers. For continuous refresh, run this as a separate process:

```bash
nexus tool auth sync-allowed-leaders \
  --out ./allowed-leaders.json \
  --interval 30s
```

The generated [Toolkit config reference](/talus-docs-v2.1.0/reference/toolkit/rust.md) defines this JSON shape: there is no top-level version field, and each Tool entry uses optional `response_signing_key`. This RegisteredKey path requires that key so the Toolkit can sign canonical result BCS. The all-zero key is test material only. Do not write a real-key copy to `$PWD/toolkit-config.json` or any other working-tree path; use the protected temporary flow below instead:

```json
{
  "invoke_max_body_bytes": 10485760,
  "signed_http": {
    "mode": "required",
    "allowed_leaders_path": "<runtime-config-dir>/allowed-leaders.json",
    "tools": {
      "xyz.taluslabs.exchanges.coinbase.spot-price@1": {
        "response_signing_key": "0000000000000000000000000000000000000000000000000000000000000000",
        "replay_cache_ttl_ms": 300000
      }
    }
  }
}
```

`invoke_max_body_bytes` defaults to 10 MiB. Instead of `allowed_leaders_path`, `allowed_leaders` may contain the active v3 allowlist schema inline: `{ "version": 1, "leaders": [...] }`. A file path is resolved from the Toolkit process working directory, not from the config file; on refresh failure, the resolver retains the previously loaded allowlist. The `tools` map is keyed by exact FQN. `replay_cache_ttl_ms` controls completed response entries and defaults to five minutes.

There is no Tool `kid` field and no external replay-store field. The Tool identity used by workflow selects the active Tool key on-chain. Replay entries are process-local memory keyed by deterministic nonce and canonical input hash; in-flight reuse returns `409 request_in_flight`, an exact completed retry returns cached canonical bytes, and a completed entry expires after its TTL.

For a real key, read from an operator-supplied protected secret-store path and build only a disposable config outside the repository. The persistent secret store remains under the operator's own rotation and access policy; this trap removes only the temporary config and key copy used by the demo. Require mode `0600` on the source and temporary key, mode `0600` on the config, and mode `0700` on the temporary directory:

```bash
export TOOL_FQN="xyz.taluslabs.exchanges.coinbase.spot-price@1"
export TOOL_SECRET_STORE="<operator-supplied-protected-secret-path>"
test -r "$TOOL_SECRET_STORE"
test "$(stat -c '%a' "$TOOL_SECRET_STORE")" = 600

umask 077
TOOLKIT_SECRET_DIR="$(mktemp -d)"
chmod 700 "$TOOLKIT_SECRET_DIR"
TOOLKIT_CONFIG_PATH="$TOOLKIT_SECRET_DIR/toolkit-config.json"
TEMP_KEY_PATH="$TOOLKIT_SECRET_DIR/tool-signing-key.hex"
cleanup_toolkit_secrets() {
  status=$?
  trap - EXIT INT TERM
  unset NEXUS_TOOLKIT_CONFIG_PATH TOOL_SECRET_STORE
  rm -f -- "$TOOLKIT_CONFIG_PATH" "$TEMP_KEY_PATH"
  for temporary_secret in "$TOOLKIT_CONFIG_PATH" "$TEMP_KEY_PATH"; do
    if test -e "$temporary_secret"; then
      printf 'temporary secret cleanup failed: %s remains\n' "$temporary_secret" >&2
      status=1
    fi
  done
  if test -d "$TOOLKIT_SECRET_DIR" && ! rmdir -- "$TOOLKIT_SECRET_DIR"; then
    printf 'temporary secret cleanup failed: %s is not empty\n' "$TOOLKIT_SECRET_DIR" >&2
    status=1
  fi
  if test -e "$TOOLKIT_SECRET_DIR"; then
    printf 'temporary secret cleanup failed: %s remains\n' "$TOOLKIT_SECRET_DIR" >&2
    status=1
  fi
  exit "$status"
}
trap cleanup_toolkit_secrets EXIT INT TERM
install -m 600 "$TOOL_SECRET_STORE" "$TEMP_KEY_PATH"
jq -n \
  --arg fqn "$TOOL_FQN" \
  --arg leaders_path "$PWD/allowed-leaders.json" \
  --rawfile key "$TEMP_KEY_PATH" \
  '{
    invoke_max_body_bytes: 10485760,
    signed_http: {
      mode: "required",
      allowed_leaders_path: $leaders_path,
      tools: {
        ($fqn): {
          response_signing_key: ($key | rtrimstr("\n")),
          replay_cache_ttl_ms: 300000
        }
      }
    }
  }' > "$TOOLKIT_CONFIG_PATH"
chmod 600 "$TOOLKIT_CONFIG_PATH"
jq -e '(has("version") | not) and .signed_http.mode == "required" and (.signed_http.tools[env.TOOL_FQN].response_signing_key | length) > 0' "$TOOLKIT_CONFIG_PATH"
export NEXUS_TOOLKIT_CONFIG_PATH="$TOOLKIT_CONFIG_PATH"
cargo run
```

The cleanup handler attempts removal on normal exit, interruption, or termination and reports any remaining file or directory. It cannot guarantee cleanup after an uncatchable kill, host crash, or filesystem failure; inspect the temporary directory and remove it manually when the handler could not run. A persistent service must receive its mode-`0600` config through its protected secret manager or mount, outside the repository, with a service-specific key rotation and stop procedure; do not reuse this disposable demo directory as the persistent store.

For the key-registration, Tool verifier-support, DAG mode, readback, and negative-evidence steps, continue with [Verify an off-chain Tool result](/talus-docs-v2.1.0/guides/tool-development/verify-offchain-tool-result.md).

#### Step 9 — Deploy for production

Terminate TLS directly in the Toolkit with both `NEXUS_TOOL_TLS_CERT_PATH` and `NEXUS_TOOL_TLS_KEY_PATH`, or use a gateway that provides authenticated HTTPS on both the public and gateway-to-Tool hops.

* Nexus leaders accept only HTTPS Tool URLs, use TLS 1.2 or newer, follow no redirects, and bypass configured proxies.
* Forward every v3 request header (`X-Nexus-Sig-V`, `X-Nexus-Leader-Id`, `X-Nexus-Leader-Key-Id`, `X-Nexus-Input-Hash`, `X-Nexus-Leader-Signature`, and `X-Nexus-Nonce`) and both response headers (`X-Nexus-Sig-V` and `X-Nexus-Tool-Signature`). Preserve the request's semantic input. Raw method/path/query/body bytes are not signed, so HTTPS protects them in transit while the Toolkit recomputes the canonical input hash.
* Keep Tool and Leader signing keys secret. V3 replay resistance uses the deterministic invocation nonce and process-local replay cache, not host-clock validity claims.
* Configure private IPv4 destinations only through the leader's explicit verified-Tool CIDR policy; signed HTTP never makes plaintext HTTP safe.

Validate the deployed endpoint, then register its final HTTPS URL:

```bash
TOOL_URL=https://spot-price.example.com
nexus tool validate offchain --url "$TOOL_URL"
nexus tool register offchain --url "$TOOL_URL"
nexus tool inspect --tool-fqn "xyz.taluslabs.exchanges.coinbase.spot-price@1"
```

Registration returns a `Tool` object plus separate `CloneableOwnerCap<OverTool>` and `CloneableOwnerCap<OverToolCashier>` capabilities. It locks an owned `Coin<US>` as registration collateral (the CLI selects one unless `--collateral-coin` is supplied), stores the Tool record, and saves the returned capability IDs unless `--no-save` is used; the SUI gas source that submits the transaction is separate. After the Tool is unregistered and its configured collateral lock has elapsed, `nexus tool claim-collateral --tool-fqn "$TOOL_FQN" --owner-cap <OVER_TOOL_CAP_ID>` reclaims the locked `$US` through the `OverTool` capability. That is collateral recovery, not SUI gas or Tool earnings. Use `--from-meta <FILE|->` only when the reviewed metadata describes the same final HTTPS endpoint; it skips live endpoint validation.

The CLI exposes `nexus tool cashier collect-invocations` for finalized Invocation receipts and `nexus tool cashier collect-deposits` for generic `CashierDeposit` objects. Prove each command through selected-build help, generated bindings, matching package IDs, tests, and transaction effects; do not substitute `claim-collateral` for either collection path.

#### Step 10 — Version your tool

The `@version` suffix in the FQN is how Nexus tracks breaking changes:

* **Changing the input or output schema is a breaking change.** Adding a required input port, removing an output port, renaming a variant, or changing a type all change the contract DAGs depend on. Bump the version — publish the new behavior as `…@2` while `…@1` stays registered and callable.
* **Non-contract changes** (a bug fix in `invoke`, faster code, a new upstream endpoint returning the same shape) can be redeployed under the same FQN.

Because old versions stay in the registry, existing DAGs that pin `…@1` keep working when you ship `…@2`.

#### Step 11 — Use the tool in a workflow

Once registered, reference the tool from a DAG by its FQN with the `off_chain` variant:

```json
{
  "vertices": [
    {
      "kind": {
        "variant": "off_chain",
        "tool_fqn": "xyz.taluslabs.exchanges.coinbase.spot-price@1"
      },
      "name": "price",
      "verifier": "registered_key",
      "entry_ports": [
        {
          "name": "currency_pair"
        }
      ]
    }
  ]
}
```

The `price` vertex's `ok` variant exposes the `amount`, `base`, and `currency` output ports, which you wire into downstream Tools via edges. The `registered_key` field is valid only after the Tool advertises RegisteredKey support; remove the field or use `"none"` for the unsigned local-only path. To run and settle a full workflow, see [Execute and settle an agent](/talus-docs-v2.1.0/guides/agent-usage/execute-and-settle-agent.md).

#### Verification checklist

You have built a complete off-chain tool when you can:

* run the tool locally (`cargo run`) and get `200` from `nexus tool validate offchain --url …`;
* register its metadata (`nexus tool register offchain …`) without an authorization error;
* fetch it back by FQN (`nexus tool inspect --tool-fqn …`);
* parse and launch an exact Toolkit JSON file through `NEXUS_TOOLKIT_CONFIG_PATH`;
* configure RegisteredKey support and reference it as an `off_chain` vertex with `"verifier":"registered_key"`;
* prove unsigned and tampered evidence are rejected, then inspect the accepted on-chain result.

#### Common failure modes

| Symptom                          | Likely cause and fix                                                                                                                                    |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FQN mismatch / tool not found    | The FQN in `fqn!` differs from the one registered or referenced in the DAG. They must match exactly, version included.                                  |
| Schema mismatch / input rejected | The DAG supplies ports that do not match `input_schema`, or the response does not match `output_schema`. Re-check with `nexus tool inspect`.            |
| Signature verification failure   | Leader/key ID is not allowlisted, an active key changed, canonical evidence differs, or a proxy stripped v3 headers.                                    |
| Unsupported output type          | The output is not an `enum` (no top-level `oneOf`), or a port serializes to a shape Nexus cannot carry. Keep ports flat and JSON-scalar where possible. |
| Registration rejected            | The Tool metadata/schema is invalid, the selected `Coin<US>` collateral is missing or insufficient, or the SUI gas source cannot pay.                   |
| Stale tool registration          | You changed the schema without bumping the version. Register a new `@version`.                                                                          |

For insufficient payment, fund the relevant TaskPaymentReserve or ExecutionPayment for the Tool cost; signer SUI address balance remains separate transaction custody.

#### Next guides

* [Build an on-chain tool](/talus-docs-v2.1.0/guides/tool-development/build-onchain-tool.md) — the Move counterpart that mutates on-chain state.
* [Verify an off-chain tool result](/talus-docs-v2.1.0/guides/tool-development/verify-offchain-tool-result.md) — make the verification path explicit.
* [Execute and settle an agent](/talus-docs-v2.1.0/guides/agent-usage/execute-and-settle-agent.md) — run this tool inside a full workflow.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.talus.network/talus-docs-v2.1.0/guides/tool-development/build-offchain-tool.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `build a script that syncs our docs to a CMS` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
