# @senpi-ai/runtime

[![npm version](https://img.shields.io/npm/v/@senpi-ai/runtime.svg)](https://www.npmjs.com/package/@senpi-ai/runtime)

Event-driven automated trading plugin for OpenClaw. Orchestrates scanners, LLM-based trade decisions, and a DSL trailing stop-loss exit engine.

## Prerequisites

- **OpenClaw** >= 2026.2.0
- **Node** >= 22
- **Senpi API key** (for live trading)

## Install

```bash
openclaw plugins install @senpi-ai/runtime
```

**Custom installs (Git checkout, `dist/`-only copy, or Docker without devDependencies):** run `npm install` in the package root (or `npm ci --omit=dev` from a lockfile) so `node_modules` includes runtime dependencies. The runtime uses **TypeBox** at load time for scanner option validation (`@sinclair/typebox` / `@sinclair/typebox/value`); if that package is missing, the gateway will fail to load the plugin with `Cannot find module '@sinclair/typebox/value'`.

## Configure

Add the plugin to your OpenClaw config (e.g. `~/.openclaw/openclaw.json` or via `openclaw config set`). Use the plugin **id** `@senpi-ai/runtime` as the key:

```json
{
  "plugins": {
    "entries": {
      "@senpi-ai/runtime": {
        "enabled": true,
        "config": {
          "stateDir": "~/.openclaw/senpi-state",
          "apiKey": "YOUR_SENPI_API_KEY",
          "logLevel": "info"
        }
      }
    }
  }
}
```

## Log level

The runtime's minimum log level defaults to `info`. Accepted values are `debug`, `info`, `warn`, and `error` (case-insensitive); anything else falls back to `info` with a one-time console warning at startup.

Resolution order (highest priority first):

1. **Plugin config `logLevel`** — set it inside the plugin `config` block in `openclaw.json`. This is the recommended place so the level lives next to the rest of the runtime's configuration and survives gateway restarts without shell env wiring.
2. **`SENPI_LOG_LEVEL` env var** — useful for one-off overrides or when running the gateway under Docker Compose where env vars are easier to inject than config patches.
3. **Default** — `info`.

```jsonc
// ~/.openclaw/openclaw.json
{
  "plugins": {
    "entries": {
      "@senpi-ai/runtime": {
        "enabled": true,
        "config": {
          "logLevel": "debug"
        }
      }
    }
  }
}
```

```bash
# Env-var alternative (ignored if plugin config logLevel is set)
export SENPI_LOG_LEVEL=warn
```

The runtime logs the resolved level and its source on plugin start:

```
[senpi-runtime] log level: info (source: default). Set plugin config "logLevel" or env SENPI_LOG_LEVEL to debug|info|warn|error to override.
```

The `source:` field will be one of `plugin config logLevel`, `env SENPI_LOG_LEVEL`, or `default`. You can confirm the active level at any time with `openclaw senpi status`, which prints a `Log Level: <level>` line for each running runtime (also available under `openclaw senpi state`).

### What each level shows

- `warn` / `error` — failures, retries, clearinghouse unavailable, scanner watchdog alerts.
- `info` (default) — the above, plus lifecycle events (open/close positions, scanner signal processed), DSL monitor ticks when there are active positions, position-tracker scans when positions or deltas change.
- `debug` — the above, plus per-tick scanner heartbeats, DSL monitor ticks with zero active positions, and full MCP tool-call request/response envelopes (with secrets redacted). Useful for diagnosing individual scan or price-fetch behavior; very noisy.

## Notifications

Delivery is **env-configured**, not a per-strategy setting: the runtime POSTs each notification to the ingest endpoint named by `NOTIFICATIONS_INGEST_URL` (default: the production endpoint `https://notifications.prod.senpi.ai/notify`), authenticated with its own Senpi token (`SENPI_AUTH_TOKEN`, falling back to `SENPI_API_KEY`). A runtime with notifications configured refuses to boot if the token is missing rather than silently dropping them.

Which strategy alerts fire is controlled by the `notifications` block in your strategy YAML (e.g. `dsl_lifecycle`, `dsl_notify_sl_updates`) — see the [strategy YAML spec](docs/runtime-docs/strategy-yaml-spec.md). The runtime emits DSL exit alerts (profit-lock, stop-raise), the daily trade/loss-limit and drawdown trips, decision-failure alerts, and market-scanner alerts; each event notifies once. Position open/close are announced by the backend, not the runtime.

Plugin lifecycle (start/stop) and auto-update status are **not** sent as notifications — they are behind-the-scenes housekeeping and go to the plugin log only.

## Auto-update

Auto-update is **enabled by default** (opt-out). When a new minor or patch version is published to npm, the plugin will install it automatically and attempt to restart the OpenClaw gateway so the new code takes effect.

- **Major versions** are never auto-installed. Availability is logged (`[senpi-auto-update]`), asking you to review the changelog and update manually.
- **Default mode:** `auto-apply` — the plugin polls every 6 hours, installs the update, and triggers a gateway restart.
- **Gateway restart** requires `gateway.reload.mode` set to `restart` or `hybrid` in your OpenClaw config. If the mode is `hot` or `off`, or the config helpers are unavailable, the plugin logs a warning that you need to restart the gateway manually.

To customise or disable auto-update, add an `autoUpdate` block to the plugin config:

```json
{
  "plugins": {
    "entries": {
      "runtime": {
        "enabled": true,
        "config": {
          "autoUpdate": {
            "mode": "notify-only",
            "pollIntervalMinutes": 720
          }
        }
      }
    }
  }
}
```

Set `"enabled": false` inside the `autoUpdate` block to disable it entirely.

**Known limitation:** If the gateway cannot be restarted automatically (wrong reload mode or missing config access), runtimes that were stopped for the update remain stopped until you restart the gateway manually.

## Risk guardrails

Strategy YAML may include a `risk` block with `guard_rails` (daily loss, drawdown, max entries per day, consecutive-loss cooldown, per-asset cooldown, and optional bypass when the day is profitable at the entry cap). Gates are evaluated in real time via MCP before each open. See [docs/risk-component.md](docs/risk-component.md) for gate order, data sources, and semantics.

## Quickstart

1. Copy a minimal recipe template and set your wallet:
   ```bash
   cp $(openclaw plugins path @senpi-ai/runtime)/examples/strategies/iguana/recipe.yaml ~/my-recipe.yaml
   # Edit my-recipe.yaml: set the strategy's wallet env var (e.g. IGUANA_WALLET) in env
   ```

2. Set environment variables (e.g. `.env`): `SENPI_API_KEY`, `WALLET_ADDRESS`.

3. Load the recipe (hot-loads; no gateway restart):
   ```bash
   openclaw senpi runtime create -p ~/my-recipe.yaml
   ```

4. List recipes:
   ```bash
   openclaw senpi runtime list
   ```

5. Watch gateway logs for scanner runs and signal processing.

## Strategy templates

Four supervised v2 strategies ship as worked examples. Each has its own README, a `recipe.yaml`, and a supervised `scanners/scan.py`:

| Strategy | Thesis | Recipe |
|------|-------|----------|
| iguana | XYZ macro index trend — broad equity exposure on Hyperliquid without picking stocks | `examples/strategies/iguana/recipe.yaml` |
| raptor | Momentum breakout on high-conviction movers | `examples/strategies/raptor/recipe.yaml` |
| turbine | Volume + runners; one shared scanner, two recipes | `examples/strategies/turbine/recipe-volume.yaml`, `recipe-runners.yaml` |
| spider | Two-leg scalp + swing trend system | `examples/strategies/spider/scalp/recipe.yaml`, `swing/recipe.yaml` |

Templates live under `examples/strategies/` in the package. See [examples/strategies/README.md](examples/strategies/README.md).

## CLI reference

**Deploy a strategy package (the supported go-live path):**

`senpi deploy` runs the whole path — funds preflight → wallet create+fund (with
`skillName`/`skillVersion` attribution from `strategy.yaml`) → install → one observed
scanner tick — as a **detached background job**. The verb returns in ~1s with a
`deployId`; monitoring is explicit polling.

- `openclaw senpi deploy -p <package-dir> --budget <usd>` — start the job (detached) and print the `deployId` plus the watch command
  - `-p, --path <dir>` — strategy package directory (contains `strategy.yaml`)
  - `--budget <usd>` — total budget across the package's wallets (min $10/wallet — the platform floor `min_budget.py` owns)
  - `--decision-model <model>` — required if the package uses `decision_mode: llm`
  - `--tick-wait <seconds>` — how long the job waits to observe one verified scanner tick (default 120, `0` skips)
  - `--max-wait <seconds>` — how long the job waits for wallets to reach ACTIVE (default 150)
  - `--json` — print the raw start response
  - **Exit codes (start):** `0` the job started · `2` refused · `1` internal/transport error. A refusal is recognisable by its message, which leads with the code (`[INVALID_REQUEST] …`, `[E_DEPLOY_IN_PROGRESS] …`) — the same `2 = refused` class `deploy status` uses, so a caller branching on the code obeys a refusal instead of retrying it. `1` is reserved for a gateway that could not be reached or whose response could not be read; on that code the deploy's state is unknown and `senpi deploy status` is the way to find out.
- `openclaw senpi deploy status [deployId]` — the job's phase while running; the full verified report once terminal. **Exit codes:** `0` live · `2` refused · `3` failed · `4` installed-unobserved · `5` interrupted · `6` pending (a wallet still funding, or the job still running) · `1` internal/transport error. The code is set on the `--json` path too. Anything richer than the overall status: read `--json`. A tick that was not observed within the window reports as `unobserved` (with the check-later command) — never as success. After a gateway restart an unfinished job reports as `interrupted`, showing both the journal history and a fresh read of what actually exists, plus the resume command.

There is no `cancel` verb: undeploying a strategy is closing it (`senpi-strategy-ops/scripts/close.py`),
not stopping the job. Every MCP call a deploy makes is deadline-bounded — the job stops *waiting* on
a call that overruns (the request may still be in flight on the server, so the report says the
outcome is unknown rather than claiming it failed) — and the job itself has a wall-clock deadline — past it the run is abandoned at its next step boundary and the slot is freed,
so a wedged deploy can never block the next one until the gateway restarts.

One deploy runs at a time (`[E_DEPLOY_IN_PROGRESS]`): concurrent deploys share one funding
waterfall and could jointly overdraw. Re-running `senpi deploy` on the same package resumes —
reconciliation reads the backend and the registry and adopts whatever already exists, waiting for a
wallet that is still initializing rather than creating a second one. A match that is paused or being
torn down is refused outright, naming its real status and what to do about it — it is never waited on
as if it were funding. Deploy never *adds* funds to a
wallet that already exists; when the requested `--budget` exceeds what an adopted wallet holds, the
report says so instead of implying the money was applied.

A package whose instances declare no DSL exit block (`exit.dsl_preset`, or `exit.engine: dsl`) is
refused before anything is created: a strategy that cannot stop itself out is never funded.

A package that **hardcodes an instrument which is not live on Hyperliquid** is refused the same way,
pre-money, before the deploy reads the backend at all (`[E_UNIVERSE_NOT_LIVE]`). A dead name does not
error at runtime — the scan skips it and the strategy silently trades nothing — so the one refusal
enumerates *every* dead name with the exact file and key path it appears in (`catalog.assets`, or an
instance's `scanners[].inputs`), plus the read-only re-check command and the re-run. A bare ticker
counts as live if either `T` or `xyz:T` is; a package that hardcodes nothing (a derived universe)
never reads the instrument list at all. Names under an **exclusion** key (`excludeAssets`, `deny…`,
`skip…`) are not hardcoded instruments in this sense and are never checked: the package is naming
what it will *not* trade, and such a list routinely names things the venue does not carry — which is
usually the reason they are on it. When the live instrument list **cannot be read**, the deploy
fails closed — the step reports the read failure with no refusal code and names no instrument dead,
because unknown is never "not live", and nothing is created either way.

**The budget is checked in two tiers, and only the first one refuses.** The **hard floor** is
$10/wallet (the platform minimum): a budget the accessible balance cannot fund at that floor halts
with `[E_FUNDS_BELOW_FLOOR]` and no wallet is created. The **soft tier** is the package's *calculated
minimum* — the smallest total at which every sleeve's smallest slot can still open, computed locally
from the package's own sizing (the same calculation `min_budget.py` bakes into the discovery card).
When a wallet this deploy funds is allocated less than its own sizing needs, it **deploys anyway**
and reports `[W_BUDGET_BELOW_STRATEGY_MIN]`, naming each short wallet and both its numbers: the
strategy runs degraded (fewer slots than authored), which is a choice, not a fault. When a sleeve
exposes no readable slot size the minimum is a lower bound and the report says so with
`[W_BUDGET_UNRESOLVED]` — plus the shortfall, if a wallet is short anyway. Both are warnings — they
never refuse, and they show up as `calculated minimum:` / `warn:` lines in `deploy status` and as
`minBudget`/`minWalletCount`/`belowMin`/`minBudgetNote`/`minBudgetUnresolved` in `--json`.
`belowMin` is a claim about the funding PLAN — set whenever the plan allocated a wallet under its own sizing, under either code, and left true if the deploy then failed; the note marks that difference in tense ("would have funded").

The shortfall is judged **per wallet**, against what `planFunding` actually allocates each one —
never whole-budget against whole-package minimum. Those differ as soon as a sleeve is adopted (the
budget is then split among fewer wallets), and comparing the totals reports shortfalls that do not
exist. `minBudget` rides the report as *context* — "deploying the whole package fresh needs $30
across 2 wallets" — not as the thing that was violated.

Running the authored design after a below-minimum deploy means **starting over**, not re-running at
a bigger number: deploy never adds funds to a wallet that already exists, so a re-run would simply
adopt the one just created. The warn names the exact sequence, **scoped to the short sleeves**
(`close.py <id> --instance <name>`, which returns their funds) so adopted live wallets beside them
are left alone — and it is omitted entirely when the deploy created no wallet at all.

**Runtime recipes:**
- `openclaw senpi runtime create` — **internal**; prefer `senpi deploy`, which adds the funds preflight, attribution and the verified tick that direct create skips. Hidden from help; still executes.
  - `--runtime-yaml-dir <dir>` — directory to resolve relative scanner paths against (content installs only). Without it, a content install whose YAML declares a relative scanner `path` is refused with `[E_VALIDATE_UNRESOLVABLE_SCANNER_PATH]` rather than silently resolving against the gateway's working directory. The refusal comes from the install gate, which also names the other ways to supply the directory.
- `openclaw senpi runtime list` — list installed runtimes
- `openclaw senpi runtime delete <runtime_id>` — remove by id

**In-shell reference:**
- `openclaw senpi guide` — overview
- `openclaw senpi guide scanners` — scanner types and config fields
- `openclaw senpi guide actions` — action types and decision modes
- `openclaw senpi guide dsl` — DSL two-phase exit engine
- `openclaw senpi guide examples` — print minimal strategy YAML
- `openclaw senpi guide schema` — full YAML schema
- `openclaw senpi guide version` — plugin version and changelog URL

## HTTP API

The runtime exposes an HTTP API on `127.0.0.1:8787` (default; configurable via plugin config). Use it to ingest signals from external sources, query audit history, and check liveness.

**Three endpoints:**
- `POST /signals` — ingest external scanner signals; body is a JSON array of signal items
- `GET /audit?address=<wallet>` — query strategy audit history via MCP
- `GET /health` — liveness probe with per-runtime signal queue depth

**Default binding:** `127.0.0.1:8787` (localhost-only). For Postman testing on the same host, flip `api.host` to `0.0.0.0` in `openclaw.json` and add a Docker port mapping `127.0.0.1:8787:8787` (the `127.0.0.1:` prefix is mandatory to avoid LAN exposure). See [docs/runtime-docs/runtime-api.md](docs/runtime-docs/runtime-api.md) for full details and the Postman testing recipe.

**Latency benchmark:** `npm run bench` runs `benchmarks/ingest-latency.ts` — compares the in-process gateway-shim path against HTTP `POST /signals` across three scenarios and prints p50/p95/p99 with a PASS/FAIL gate.

## External scanners

Use `type: external_scanner` when the signal or context source lives outside the
runtime and should push data in through the gateway instead of being scheduled
on an `interval`.

```yaml
scanners:
  - name: funding_arb
    type: external_scanner
    outputs:
      signals: true
      context: false
    retention: rolling_window
    retention_max_runs: 50
    config:
      fields:
        funding_rate: { type: number, required: true }
        spread: { type: number, required: true }
        exchange: { type: string }
```

Ingest data with the gateway RPC:

```bash
openclaw gateway call senpi.ingestExternalScannerData --params '{
  "address": "0xYourStrategyWallet",
  "scanner": "funding_arb",
  "asset": "ETH-PERP",
  "direction": "LONG",
  "score": 0.91,
  "signal_type": "EXTERNAL_FUNDING_ARB",
  "data": {
    "funding_rate": 0.015,
    "spread": 0.003,
    "exchange": "binance"
  }
}'
```

Prompt access stays namespaced:

- `{{signal_funding_arb}}` for ingested signals
- `{{context_custom_regime}}` for retained external context
- flat aliases like `{{funding_arb}}` are not created

## Guide skill

Once published, the **@senpi-guide** skill on ClawHub provides the same reference in chat. Install with `clawhub install skills/senpi-guide/` (or from the ClawHub UI).

## Install on an existing OpenClaw setup

If you already have OpenClaw installed (local, VPS, or self-hosted), see [Installing on existing OpenClaw](docs/install/existing-openclaw.md) for the full install sequence, config stanza, and troubleshooting.

## Docs

- [Architecture](docs/runtime-docs/design/architecture.md)
- [Strategy YAML specification](docs/runtime-docs/strategy-yaml-spec.md)
- [Integration guide](docs/runtime-docs/integration-guide.md)
- [Runtime API](docs/runtime-docs/runtime-api.md)

## Changelog

[CHANGELOG.md](CHANGELOG.md)

## Development

```bash
npm run build            # install deps + compile TypeScript (excludes *.test.ts)
npm run typecheck:tests  # typecheck all src including tests (no emit)
npm test                 # unit + integration tests (no credentials required)
npm run dev              # build + run
```

Pre-commit runs `npm run build`, `npm run typecheck:tests`, and `npm test`. See [CLAUDE.md](CLAUDE.md) and [docs/runtime-docs/](docs/runtime-docs/) for conventions and design.
