---
name: basis-worker
description: Register and run a Basis GPU worker that can serve verified inference jobs and earn $BASIS when worker routing is live. Use this when a user wants to put an idle GPU (or any machine) to work on the Basis coordinated inference network — set up Ollama/vLLM, register a reward wallet, generate the worker config, start the worker, and verify it is online.
---

# basis-worker — set up a Basis contributor inference worker

Turn an idle GPU (or any machine) into a **Basis** worker: a node that connects to
a Basis orchestrator, registers a **public** EVM reward wallet, and serves
OpenAI-compatible inference jobs. Basis is the inference layer for the agent
economy (https://basis.watch). A worker holds **no private key**, signs nothing,
and performs **no on-chain action**.

> **Honest state (do not overstate this to the user).** Worker **registration**
> is live + durable today. Worker **job ROUTING is OPT-IN and currently OFF in
> production** — so a registered worker is **"registration ready / routing being
> activated"** and **cannot receive production jobs yet**. On-chain reward
> **settlement/claiming is gated (dry-run)**. So: set the user up to register and
> run a worker now; tell them clearly that earning is not active yet. There is
> **no guaranteed income**. Confirm current state with
> `GET https://basis.watch/api/workers/onboarding/status` (or
> `node scripts/verify-worker.mjs --onboarding`).

## What this does

Walks an agent through, end to end:

1. Check prerequisites (Node ≥20, optionally Docker + an NVIDIA GPU).
2. Help the user connect a **wallet** at basis.watch (Privy or a browser wallet)
   to get a **public** reward address — never a private key.
3. Register the worker (`POST /api/workers/register`, strict EVM-address check).
4. Generate a **local-only** worker `.env` (chmod 600) from the template.
5. Choose a runtime: **simulator (`echo`, GPU-free)**, **Ollama**, or **vLLM**.
6. Run the worker against an orchestrator and verify it shows **online**.
7. **Publish a SIGNED price/model manifest** to the market (the worker's ask
   prices + capacity, signed by the reward wallet — see "Broadcast a price/model
   manifest" below).
8. Run the local canary to prove the orchestrator ⇄ worker path end to end.
9. Explain rewards + settlement honestly (gated; no earnings yet).

## Commands (the flow at a glance)

These are the steps the skill walks through. They map to the scripts in
`scripts/` (this skill has no single `basis-worker` binary — invoke the `.mjs`
helpers, or alias them as shown):

| Command (alias) | Script | What it does |
|---|---|---|
| `basis-worker setup` | `setup-worker.mjs --check` / `--write-env` | Detect GPU/Docker/Ollama/vLLM; write a `0600` worker `.env`. |
| `basis-worker register` | `setup-worker.mjs --register` | `POST /api/workers/register` with the **public** reward wallet. |
| `basis-worker price publish` | `publish-prices.mjs` | Sign a price/model manifest with the reward wallet + `POST /api/workers/prices`. |
| `basis-worker canary` | `pnpm worker:canary` | Prove the orchestrator ⇄ worker ⇄ `/v1/dispatch` path locally. |
| `basis-worker status` | `verify-worker.mjs` / `healthcheck.mjs` | `GET /api/workers/:id` — is the worker online + its reward ledger. |
| `basis-worker stop` | Ctrl-C / `docker compose … down` | Stop the worker (clean `disconnect`; no key, no on-chain state). |

Optional shell aliases (for convenience only — the scripts are the truth):

```bash
alias basis-worker-setup='node .agents/skills/basis-worker/scripts/setup-worker.mjs'
alias basis-worker-publish='node .agents/skills/basis-worker/scripts/publish-prices.mjs'
alias basis-worker-status='node .agents/skills/basis-worker/scripts/verify-worker.mjs'
```

## SAFETY RULES (read first — these are non-negotiable)

- **Never ask for, accept, generate, or store a seed phrase or private key.** The
  reward wallet is a **PUBLIC address only** (`0x` + 40 hex). If a user pastes a
  64-hex / 0x64-hex string or a 12/24-word phrase, **STOP** and tell them never to
  share it — it is not needed.
- **Signing happens only in the user's own wallet** (Privy-linked or a browser
  wallet at basis.watch). This skill and the worker **sign nothing** and hold no
  key.
- **The worker auth token is a SECRET stored LOCAL-ONLY** in a `0600`-permission
  `.env` on the worker host. **Never** print it, paste it into chat, write it to
  the repo, or commit it. Never echo the values of `BASIS_WORKER_TOKEN`,
  `BASIS_WORKER_AUTH_KEY`, `BASIS_ORCHESTRATOR_SECRET`, or `INTERNAL_API_SECRET`.
- **Never log secrets.** The worker prints the **public** wallet only; the auth
  token is never logged. Keep it that way — do not add logging that would expose a
  token/key.
- **`.env` is gitignored — keep it that way.** Never `git add` a worker `.env`.
- **No financial promises.** Do not say "earn now", "passive income", "yield",
  "APY", "guaranteed rewards/returns", or "risk-free". Rewards are payment for
  completed, verified work and are **gated/not live**.
- **If anything is unsure or a step would expose a secret or sign a transaction,
  STOP and ask the operator.** Do not guess at a private key, a production secret,
  or an on-chain action.

## Prerequisites

- **Node.js ≥ 20** on the worker host (`node -v`).
- A **public EVM wallet address** for rewards (obtained below — never a key).
- For **real** model inference, one of:
  - **[Ollama](https://ollama.com)** (easiest; OpenAI-compatible API on `:11434`),
    or
  - **[vLLM](https://docs.vllm.ai)** (`vllm/vllm-openai`, OpenAI-compatible API on
    `:8000`).
  - For GPU passthrough in containers: the **NVIDIA Container Toolkit** + Docker,
    so `docker run --gpus all …` can see the GPU.
- For a **GPU-free smoke test** you need none of the above — use the `echo`
  (simulator) runtime.

Run the detector to report OS / GPU / Docker / Ollama / vLLM and what is missing:

```bash
node .agents/skills/basis-worker/scripts/setup-worker.mjs --check
# or the shell entrypoint (delegates to the .mjs):
bash .agents/skills/basis-worker/scripts/setup-worker.sh --check
```

## Wallet setup (PUBLIC address only — never a key)

The reward wallet is **identity only**: the destination for any future $BASIS
reward. It is **public** and is **not** a key.

1. Open **https://basis.watch** and connect a wallet — either a **Privy**-linked
   wallet (email/social → embedded wallet) or an **injected browser wallet**
   (MetaMask, Coinbase Wallet, etc.).
2. Copy the wallet's **public address** (`0x` + 40 hex).
3. Use that address as `BASIS_WORKER_WALLET`.

Never request, accept, or store the wallet's private key or seed phrase. Signing
(if ever needed, e.g. claiming a future reward) happens in the user's wallet at
basis.watch — not here.

## Register the worker

Registration records the worker + its **public** reward wallet (it persists to the
durable Basis store; the EVM address is strictly validated):

```bash
node .agents/skills/basis-worker/scripts/setup-worker.mjs \
  --register \
  --wallet 0xYourPublicEvmAddress0000000000000000000000 \
  --worker-id my-gpu-1 \
  --base https://basis.watch
```

This `POST`s to `https://basis.watch/api/workers/register`:

```json
{ "worker_id": "my-gpu-1", "worker_wallet": "0x…", "runtime": "ollama", "models": ["basis-default"] }
```

A `400` means a malformed wallet (must be a valid EVM address). When
`BASIS_AUTH_REQUIRED` is enabled on the server, registration must be authenticated
and the wallet must be one of the caller's Privy-linked wallets (`403
wallet_not_linked`) — pre-launch this is open.

## Generate the worker token / config

Generate a **local-only** `.env` from the template (never committed, `chmod 600`):

```bash
node .agents/skills/basis-worker/scripts/setup-worker.mjs \
  --write-env \
  --wallet 0xYourPublicEvmAddress0000000000000000000000 \
  --runtime ollama \
  --orchestrator wss://your-orchestrator.example \
  --out ./worker.env
chmod 600 ./worker.env   # the generator also does this; verify it
```

Worker handshake **token precedence** (the worker derives it; you do not paste a
key into chat):

1. `BASIS_WORKER_TOKEN` — a pre-minted or allowlisted token the operator gave you.
2. else `BASIS_WORKER_AUTH_KEY` — the shared HMAC key the operator set on the
   orchestrator; the worker derives a **wallet-bound** token locally
   (`<wallet>.<HMAC-SHA256(key, wallet)>`) so a leaked token can't register a
   different wallet. **Recommended** against a configured orchestrator.
3. else a **dev-only** default (`basis-worker:<wallet>`) — accepted only by a
   dev-mode orchestrator with no worker-auth configured (use for local testing).

`BASIS_WORKER_TOKEN`, `BASIS_WORKER_AUTH_KEY`, and `BASIS_ORCHESTRATOR_SECRET` are
**secrets**: they live in the `0600` `.env` only, are never logged, and are never
pasted into chat or the repo. The generated `.env` leaves them blank with a note —
the operator fills them in on the host.

## Choose a runtime

| Runtime | GPU? | Use when |
|---|---|---|
| **`echo` (simulator)** | No | Prove the pipeline end to end with no model (dev smoke). Streams a deterministic canned response. |
| **`ollama`** | Yes (recommended) | Easiest real inference. OpenAI-compatible API on `:11434`. |
| **`vllm`** | Yes | High-throughput serving via an OpenAI-compatible API (`vllm/vllm-openai` on `:8000`). **Not a direct `BASIS_WORKER_RUNTIME` value today** — the worker CLI speaks `ollama`/`echo`; bridge vLLM via an Ollama-compatible shim or the orchestrator backend (see note below). |

**Ollama (Docker, GPU):**

```bash
# Needs the NVIDIA Container Toolkit so --gpus all works.
docker run -d --gpus all -p 11434:11434 -v ollama:/root/.ollama --name ollama ollama/ollama
docker exec ollama ollama pull llama3.2
```

Or use the bundled compose (Ollama + worker, with GPU device reservations):

```bash
# fill ./worker.env first (BASIS_WORKER_WALLET etc.), then:
docker compose -f .agents/skills/basis-worker/templates/docker-compose.worker.yml --env-file ./worker.env up -d
```

**vLLM (Docker, GPU):**

```bash
docker run -d --gpus all -p 8000:8000 --name vllm \
  vllm/vllm-openai:latest --model mistralai/Mistral-7B-Instruct-v0.3
# OpenAI-compatible API at http://localhost:8000/v1
```

**H100 note:** a single H100 (80GB) comfortably serves a 7B–13B model at high
throughput; larger models (70B+) need tensor-parallel across multiple GPUs
(`--tensor-parallel-size N` for vLLM). Start with a 7B/8B instruct model to verify
the worker, then scale the model to fit the card.

> The Basis worker CLI (`runtime/worker`) currently speaks to **Ollama** (or the
> GPU-free `echo` runtime) directly. For a **vLLM** backend today, run vLLM as your
> OpenAI-compatible server and point an Ollama-compatible shim or the orchestrator
> backend at it; the simplest verified path is **Ollama** or **`echo`**. (See
> `resources/troubleshooting.md`.)

## GPU checks

```bash
nvidia-smi                       # GPU present + driver + free VRAM
docker info | grep -i runtime    # nvidia runtime available (Container Toolkit)
node .agents/skills/basis-worker/scripts/setup-worker.mjs --check
```

No NVIDIA GPU? You can still run the **`echo`** runtime (CPU, no model) to validate
registration + the orchestrator path. (Apple Silicon / AMD users: run Ollama
natively on the host rather than via `--gpus all`.)

## Install deps & start

The worker is the standalone CLI in **`runtime/worker`** (its own
`package.json`/`node_modules`, NOT part of the pnpm workspace). Do not import it
from `apps/web`.

```bash
cd runtime/worker
npm install

# GPU-free smoke (no model, proves the pipeline):
BASIS_WORKER_WALLET=0xYourPublicEvmAddress0000000000000000000000 \
BASIS_WORKER_RUNTIME=echo \
npm run dev

# Real inference via Ollama:
BASIS_WORKER_WALLET=0xYourPublicEvmAddress0000000000000000000000 \
BASIS_WORKER_RUNTIME=ollama \
BASIS_OLLAMA_MODEL=llama3.2 \
BASIS_WORKER_MODELS=llama3.2 \
BASIS_ORCHESTRATOR_URL=wss://your-orchestrator.example \
npm run dev
```

Or load the `0600` `.env` you generated: `cd runtime/worker && node --env-file
../../worker.env $(npm pkg get scripts.dev …)` — simplest is to copy `worker.env`
to `runtime/worker/.env` (gitignored) and run `npm run dev`.

Need an orchestrator? Self-host `runtime/orchestrator` (`npm install && npm run
dev` on `:8787`) or point `BASIS_ORCHESTRATOR_URL` at one the operator gave you.
There is **no hosted orchestrator for arbitrary contributors yet** — this is the
"routing being activated" state.

## Verify heartbeat / online

Liveness to the orchestrator is the **persistent Socket.io connection** (its
transport ping/pong keeps the link alive; a `disconnect` = offline). The worker
also prints an operator-facing heartbeat log (~every 30s: connected / worker id /
idle vs busy / jobs completed). Check the worker's record over HTTP:

```bash
node .agents/skills/basis-worker/scripts/verify-worker.mjs \
  --worker-id my-gpu-1 --base https://basis.watch
# GET /api/workers/:id → { worker: { online, jobsCompleted, … } }

node .agents/skills/basis-worker/scripts/healthcheck.mjs --worker-id my-gpu-1
```

`online` is derived against a heartbeat window, so a stale worker reads offline.

## Run the canary (prove the path end to end)

From the repo root, the local canary spawns a **real** orchestrator + a **real**
`echo` worker with throwaway secrets and a throwaway wallet, then drives the
gateway's `POST /v1/dispatch` path:

```bash
pnpm worker:canary
```

It proves: no-worker → `503 no_worker_available`; an unauthenticated worker is
**refused**; an authenticated `echo` worker registers + shows online; a dispatched
job returns `200` with `x-basis-worker-id` / `x-basis-worker-wallet` /
`x-basis-payable` headers. Everything is local + throwaway — no real wallet, no
real secret, no key, no on-chain action.

## Broadcast a price/model manifest (`price publish`)

A registered worker tells the market which models it serves and what it asks per
1M tokens by publishing a **SIGNED** manifest to
`POST https://basis.watch/api/workers/prices`. Basis verifies the signature
recovers the reward wallet, the prices are within the rails, and the manifest is
not expired, then stores an immutable offer. The offer starts **`self_reported`**;
a Basis canary can later mark it **`verified`**.

**Pricing is in USDC-pegged CREDITS.** One **whole credit = 1e6 raw
micro-credits**, pegged at **$0.001 / credit**. Prices are **raw micro-credits per
1,000,000 tokens**, as decimal **strings** (never floats). So 460 whole
credits/1M = `460000000` raw. The credit price is stable in USD; only credits→
$BASIS settles dynamically at quote time (shown as an estimate, never a promise).

**Ask-price rails (server-enforced — an out-of-range price is rejected):**

| Rail | Raw / 1M | Whole credits | ~USD/1M |
|---|---|---|---|
| Prompt floor | `10000000` | 10 | $0.01 |
| Output floor | `20000000` | 20 | $0.02 |
| Max (both sides) | `12000000000` | 12000 | $12 |
| `min_job_credits` floor | `50000000` | 50 | $0.05 |
| **Network default ask** | `460000000` prompt / `1840000000` output | 460 / 1840 | $0.40 / $1.60 |

A price above ~`6000000000` ($6/1M) is **flagged (high)** but still routable.

**Steps:**

1. Edit a **local** prices config from the template
   (`templates/prices.example.json`): your `hardware` (self-reported), and one or
   more `models` (`model_id`, `runtime`, `prompt_credits_per_1m`,
   `output_credits_per_1m`, `min_job_credits`, `max_context_tokens`,
   `max_output_tokens`, `concurrency`). Start from the network default ask.
2. Make sure the **reward-wallet PRIVATE KEY is in a LOCAL source you control** —
   a `0600` key file, an env var, or a macOS Keychain ref. **Never** pass it on the
   command line, print it, or commit it. (This is the one place the flow signs;
   the worker daemon itself still holds no key.)
3. Sign + publish:

```bash
# Recommended: a 0600 file holding ONLY the reward-wallet hex key.
chmod 600 ./worker-reward.key

node .agents/skills/basis-worker/scripts/publish-prices.mjs \
  --worker-id my-gpu-1 \
  --prices ./prices.json \
  --key-file ./worker-reward.key \
  --base https://basis.watch
# Or read the key from an env var (default BASIS_WORKER_PRIVATE_KEY):
#   --key-env BASIS_WORKER_PRIVATE_KEY
# Or a macOS Keychain generic-password ref:
#   --keychain basis-worker-reward-key:my-gpu-1
# Build + sign + self-verify WITHOUT posting:
#   --dry-run
```

The script **signs locally** (EIP-191 `personal_sign` over the canonical
sorted-key JSON of the manifest with the `signature` field excluded), self-checks
the signature, and POSTs the snake_case manifest. It prints the **public**
`offerId` / `priceVersion` / `state` — **never** the private key.

> **Key safety.** The private key is read from a local file / env / Keychain and
> used only to compute one signature in memory; it is **never** printed, logged,
> written, committed, or sent over the wire. Only the public signature + the public
> reward address leave the machine. The script **refuses** a key passed on the
> command line (it would land in shell history). `viem` is required for signing —
> it is already a dependency of `apps/web`; in the standalone worker runtime run
> `npm i viem` first.

**Verification states (honest):**

- **`self_reported`** — valid signature + within rails + not expired. Capability is
  **NOT** cryptographically proven (free-form text can't prove which model produced
  it).
- **`verified`** — additionally passed a recent Basis deterministic canary
  (throughput + coherence). **`verified` ≠ proof of model identity**; hardware and
  exact model identity stay self-reported.
- **`stale`** — the worker stopped heartbeating (read-time overlay).
- **`expired`** — `valid_until` passed.

The reward wallet that signs the manifest **must match** the registered worker
wallet (a stranger can't publish prices for someone else's worker), and when
`BASIS_AUTH_REQUIRED` is on it must be one of the caller's Privy-linked wallets.
**Publishing a price is not earning** — workers earn **only** for completed,
verified, worker-routed jobs (see below). There is no guaranteed income, no yield,
and no passive income from publishing an offer.

## Rewards + settlement (honest framing — no earnings yet)

- A **COMPLETED, anti-cheat-verified, worker-routed** job with a **valid EVM
  reward wallet** accrues a **pending, off-chain** $BASIS reward.
- **`failed` / `rejected` jobs pay 0.** **Azure/gateway-served jobs pay 0** (no
  contributor wallet). Only worker-routed completions earn.
- The orchestrator's **anti-cheat verdict** (`payable`) is authoritative — output
  faster than any real GPU, or incoherent/garbage output, is **not payable**.
- **On-chain payout/claiming is GATED (dry-run).** Rewards are computed + tracked
  off-chain but are **not yet claimable on-chain** (settlement is held behind the
  settlement gates). Reward amounts are integer base units as decimal strings.
- **Worker job ROUTING is OPT-IN and currently OFF in production**, so a registered
  worker **does not receive production jobs yet** ("registration ready / routing
  being activated"). Tell the user this plainly.
- Rewards are **payment for completed verified work — not earnings on a holding,
  not yield, not passive income, not guaranteed.**

Inspect a worker's own state + reward ledger:

```bash
# GET /api/workers/:id            → worker record
# GET /api/workers/:id/rewards    → reward ledger (pending / settled)
```

## Troubleshooting

See `resources/troubleshooting.md`. Common cases: `nvidia-smi`/`--gpus all` not
working (install the NVIDIA Container Toolkit), worker exits immediately
(`BASIS_WORKER_WALLET` missing/malformed → must be `0x` + 40 hex), handshake
refused (token/auth-key mismatch with the orchestrator), `503
no_worker_available` (no eligible idle worker is connected), worker never online
(orchestrator URL wrong / firewall / not running).

## Stop the worker

```bash
# Foreground CLI: Ctrl-C.
# Docker:
docker compose -f .agents/skills/basis-worker/templates/docker-compose.worker.yml down
docker rm -f ollama vllm 2>/dev/null || true
```

Stopping is safe and immediate: the orchestrator treats the `disconnect` as the
worker going offline; no on-chain state and no key are involved.

## Security checklist (verify before "done")

- [ ] No private key / seed phrase was ever requested, generated, printed, or
      committed. The reward wallet is a **public** address in registration/config;
      the only signing step (`price publish`) reads the key from a **local** `0600`
      file / env / Keychain, uses it in memory, and **never** prints or stores it.
- [ ] `BASIS_WORKER_WALLET` is a **public** `0x`+40-hex address.
- [ ] The worker `.env` and any reward-wallet key file are `0600` and **not**
      committed (gitignored). No key was passed on the command line.
- [ ] No secret (`BASIS_WORKER_TOKEN` / `BASIS_WORKER_AUTH_KEY` /
      `BASIS_ORCHESTRATOR_SECRET` / `INTERNAL_API_SECRET`) was printed, logged, or
      pasted into chat/the repo.
- [ ] No earnings/yield/APY/passive-income/guaranteed-return language was used —
      publishing a price is **not** earning; only completed verified jobs pay.
- [ ] The user was told routing is being activated (no production jobs yet) and
      settlement is gated (no on-chain payout yet).

## Grounding / sources

- Claude Code Agent Skill format (frontmatter `name` = dir name, lowercase-kebab,
  <40 chars + `description` with the activation trigger; concise actionable body):
  https://code.claude.com/docs/en/skills · https://agentskills.io/specification
- vLLM Docker (OpenAI-compatible, `--gpus all`, `:8000`):
  https://docs.vllm.ai/en/stable/deployment/docker/
- Ollama + NVIDIA Container Toolkit + `--gpus all` (OpenAI-compatible, `:11434`):
  https://ollama.com · https://www.aimadetools.com/blog/ollama-docker-setup-guide/
- Basis worker internals: `runtime/worker/README.md`,
  `docs/inference/WORKER_SETUP.md`, `scripts/worker-canary.mjs`,
  `apps/web/app/api/workers/*`.
- Market price manifest (canonical signing + rails + verification states):
  `apps/web/app/api/workers/prices/route.ts`, `apps/web/lib/workers/offers.ts`
  (`canonicalOfferMessage`/`stableStringify`), `apps/web/lib/workers/offer-rails.ts`.
