Skip to content

Docs

Operators: publish signals#

How an operator sends signals, the exact request, and what a follower sees. A signal says what share of a follower's money to put where, never an absolute order size.

Testnet only. A signal is a statement your followers' mandates size and check themselves; publishing one places no order for anyone.

Who can publish, and who is listed#

Two different gates. Do not mix them up.

To publish (POST /forge/vaults/{vault_id}/signals): you must be the operator of that Forge vault. Your agent key with scope tx works when its user owns the vault. An NTRT key works when its registration belongs to the same account as the vault. The route does not ask for an SBT.

To be listed (door 3 and the gallery): all of these, checked by NightWatch when the list is read:

NeededMeans
Minted SBTnw_sbt_registry has a row with status = minted and a token id for your account
NTRT registrationAn active row in nw_ntrt_registry for your account (a paused registration does not count)
An open verified vaultA Forge vault of yours with status pilot, promotion_requested or reviewed, and a verified address
For door 3 onlyThe vault is not paused and has at least one signal in the last 30 days

Door 4 (kind=vault) needs the first three. House books are listed too, tagged house, with no SBT or NTRT required. The rule is rechecked on every read: an operator who stops qualifying disappears from the lists without any row changing. The exact SQL is in docs/torii-wallet/pm/TORII_DOORS_OPERATOR_RULE.md. No new DragonGlass mark types are involved.

Get an NTRT key#

Use this when your publisher is a script and you want a key that can publish and nothing else. An agent key with scope tx also works, and then you can skip this section.

  1. Register: POST /ntrt/register with your agent key or session. Body {"kind": "human" | "agent" | "bot", "name": "<1-80 chars>", "tagline"?: "<280 chars>", "methodology_url"?: "<url, 400 chars>"}. Answers {"ok": true, "id": <ntrt_id>, "status": "active", ...}. A name is unique per account; a repeat is 409 ntrt_name_taken. There is a daily registration limit per account and kind (429 ntrt_register_rate_limited). It answers 503 while the registry is switched off on the host.
  2. Mint a key: POST /ntrt/{ntrt_id}/api_keys with {"label"?: "<80 chars>"}, owner only. The raw key nw_ntrt_... is in the answer once and never again (api_key, header). Several keys can be active at once; rotate by minting a new one and POST /ntrt/{ntrt_id}/api_keys/{key_id}/revoke. GET /ntrt/{ntrt_id}/api_keys lists them with last_used_at.
  3. Mint the SBT through the normal SBT flow (POST /sbt/request, GET /sbt/mine; MCP sbt_request).

The NTRT registry also has its own signal route, POST /ntrt/{ntrt_id}/signals. That is a different feed (conviction signals under your NTRT name). It is not the Forge signal this chapter describes, and followers' mandates do not read it.

Publish a signal#

POST /forge/vaults/{vault_id}/signals

Switched off by default on a host: while NW_FORGE_SIGNAL_PUBLISH_ENABLED is off, every call answers 503 flag_off and writes nothing.

Headers#

HeaderNeededMeaning
X-NW-User-Key: <agent key>one of the twoYour agent key, scope tx; its user must be the vault's operator
X-NW-Ntrt-Key: nw_ntrt_... and X-NW-Ntrt-Id: <ntrt id>one of the twoAn NTRT key; both headers, the id is a whole number. The key is only accepted for that registration's own account
X-NW-Idempotency-Key: <string>yes1 to 200 characters. Becomes the signal's event_id. Reuse it to retry safely
Content-Type: application/jsonyes

If you send both key styles, the agent key is used. With neither: 401 auth_required.

Body#

FieldTypeRule
kindstringopen, rebalance, trim or universe
marketstring, 1 to 64xyz:SYMBOL or main:SYMBOL, for example xyz:SKHX. It must resolve on Hyperliquid the same way a follower's plan resolves it
sidestringbuy or sell
target_weightnumber0 to 1 inclusive: the share of a follower's NAV
target_leveragenumberAbove 0 and at most the vault's leverage cap: the vault's rule 3 (target_leverage), or its mandate's size_leverage.max_leverage when that is tighter
ref_pricenumberAbove 0: the price the signal was struck at
ref_price_sourcestring, 1 to 64Where it came from, for example hl_mark
reasonstring, 1 to 500Why, in a sentence or two. Followers read it
rules_hashstringThe vault's current rules hash. Read it from GET /forge/vaults/{vault_id}/signals (rules_hash)
supersedes_idinteger, optionalAn earlier signal of this same vault that this one replaces
event_idstring, optionalIf given it must equal X-NW-Idempotency-Key

There is no quantity field. A signal is a weight and a leverage; each follower's mandate turns it into an order sized to its own caps.

What the server checks, in order#

  1. The flag is on.
  2. Who you are, and for an agent key the tx scope.
  3. X-NW-Idempotency-Key is present and at most 200 characters.
  4. You are the vault's operator.
  5. Replay: if this key and market already exist, the stored signal comes back (see below). This happens before any later check, so a retry always resolves.
  6. The vault is not paused, and has machine-readable rules.
  7. rules_hash equals the vault's current hash.
  8. Field rules: kind, side, target_weight, the leverage cap, supersedes_id, then the market.
  9. The hourly rate limit: at most 60 signals per vault per rolling hour (the host can set another number).

A refused call writes nothing. Signals are append-only: a published signal cannot be edited or deleted. To correct one, publish a later signal with supersedes_id.

Example, curl#

curl -X POST https://api.nightwatchprotocol.com/forge/vaults/42/signals \
  -H 'X-NW-User-Key: <your agent key>' \
  -H 'X-NW-Idempotency-Key: rebal-2026-10-15-SKHX' \
  -H 'Content-Type: application/json' \
  -d '{"kind":"rebalance","market":"xyz:SKHX","side":"buy","target_weight":0.25,"target_leverage":1.5,
       "ref_price":101.5,"ref_price_source":"hl_mark","reason":"monthly rebalance",
       "rules_hash":"<current rules_hash of vault 42>"}'

With an NTRT key, replace the first header with -H 'X-NW-Ntrt-Key: nw_ntrt_...' -H 'X-NW-Ntrt-Id: 12'.

Answer, 201:

{
  "ok": true, "idempotent_replay": false,
  "id": 9031, "vault_id": 42, "event_id": "rebal-2026-10-15-SKHX",
  "ts": "2026-10-15T09:12:04+00:00",
  "kind": "rebalance", "market": "xyz:SKHX", "side": "buy",
  "target_weight": 0.25, "target_leverage": 1.5,
  "ref_price": 101.5, "ref_price_source": "hl_mark", "reason": "monthly rebalance",
  "rules_hash": "<hash>", "supersedes_id": null,
  "created_at": "2026-10-15T09:12:04+00:00", "live": true
}

The values are illustrative.

Example, MCP#

forge_signal_publish (write, idempotent). It takes the same fields, with idempotency_key as an argument instead of the header, and ntrt_id plus ntrt_key instead of an agent key when you use an NTRT key.

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"forge_signal_publish","arguments":{
  "vault_id":42,"idempotency_key":"rebal-2026-10-15-SKHX","kind":"rebalance","market":"xyz:SKHX","side":"buy",
  "target_weight":0.25,"target_leverage":1.5,"ref_price":101.5,"ref_price_source":"hl_mark",
  "reason":"monthly rebalance","rules_hash":"<current rules_hash of vault 42>"}}}

A refusal comes back as {error: {reason, message, retry_safe}}.

Replay#

Send the same X-NW-Idempotency-Key and the same market again and you get the stored signal back with HTTP 200 and idempotent_replay: true. No second row is written.

If that key was already used on that market for a different signal (any of kind, side, target_weight, target_leverage, ref_price, ref_price_source, reason, rules_hash or supersedes_id differs), the answer is 409 idempotency_conflict, and nightwatch.differing_fields names the fields that differ. Use a new key, or resend exactly the stored values.

Refusals#

Standard nightwatch block; branch on reason. A refused call wrote nothing.

reasonHTTPretry_safeWhenFix
flag_off503falsePublishing is switched off on this hostNothing you can change
auth_required401falseNeither key style was sentSend X-NW-User-Key, or X-NW-Ntrt-Key with X-NW-Ntrt-Id
scope_denied403falseYour agent key lacks txAsk the key's owner
not_owner403falseThe key's account is not this vault's operatorUse the operator's key
not_found404falseNo such vaultCheck vault_id
invalid_parameter400falseMissing X-NW-Idempotency-Key, a key over 200 characters, X-NW-Ntrt-Id missing, event_id that differs from the key, a bad kind/side/target_weight/target_leverage/supersedes_id/market, or a vault with no rulesRead detail and the field it names
account_paused403falseThe vault is pausedResume it first
rules_hash_stale409falserules_hash is not the vault's current oneRead current_rules_hash from the answer, send it
idempotency_conflict409falseSee ReplayUse a new key
rate_limited429trueThe vault passed its hourly limitWait until retry_at and repeat

An NTRT key that is wrong or revoked is refused 401 with ntrt_api_key_invalid (no key at all: ntrt_api_key_missing); these two come from the NTRT registry and are plain detail strings, not the nightwatch block.

Examples:

{"detail": "rules_hash mismatch", "current_rules_hash": "<hash>",
 "nightwatch": {"schema": "nightwatch.refusal.v1", "reason": "rules_hash_stale", "retry_safe": false,
                "current_rules_hash": "<hash>"}}
{"detail": "target_leverage 2.0 is above this vault's leverage cap 1.5 (rules rule 3 / mandate size_leverage.max_leverage)",
 "nightwatch": {"reason": "invalid_parameter", "retry_safe": false, "field": "target_leverage"}}
{"detail": "this vault already published 60 signals in the last hour",
 "nightwatch": {"reason": "rate_limited", "retry_safe": true, "retry_at": "2026-10-15T10:02:11+00:00"}}

For you as the vault's operator, the next[] steps of a rules_hash_stale block are: GET /forge/vaults/{vault_id} to read the vault's current rules, then POST /forge/vaults/{vault_id}/signals again with rules_hash set to the current hash.

What followers see#

  • Live signals (younger than 24 hours): a paid read, 10 Cherry or 0.10 USDC over x402 per signal, per payer. GET /forge/vaults/{id}/signals/live always answers 200; a signal the caller has not paid for is counted in locked_count, with payment_required. The daily free reads never cover live signals.
  • Older signals (over 24 hours): free. GET /forge/vaults/{id}/signals returns them, with counts, latest_signal_at, mirroring_paused and the current rules_hash.
  • A follower's plan for a live signal costs the same as reading it, and is free if they already paid to read that signal. A plan for a signal over 24 hours old is free. See Follow a signal.
  • A follower who consented to an older rules_hash must consent again after you change the vault's rules.
  • A signal carries kind, market, side, target_weight, target_leverage, ref_price and reason. Never a size in coins or dollars.

The gallery lists what an operator offers. It is separate from publishing: you can publish signals without a listing, and the operator rule applies to both.

POST /torii/gallery (agent key scope tx, or a session). Answers 201 {"id": 17, "status": "listed", "href": "/torii/gallery/17", "note": "..."}.

FieldTypeRule
product_typestringSee the table below
titlestring2 to 80 characters
summarystring1 to 140 characters
ref_vault_idintegerOne of your own open Forge vaults. Needed for signal_subscription and vault_deposit
editable_paramslistOnly for strategy_template: 1 to 12 items, each exactly {name, type: "int" or "float", min, max, default}, min <= default <= max, names unique, lowercase letters, digits and underscore
citedlistUp to 10 claims, see Provenance below
price_modelstringfree (default) or paid_read
product_typeOffered now?Needs
signal_subscriptionYesOperator kind signal (a non-paused vault with a signal in the last 30 days) and ref_vault_id of your own open vault. paid_read is allowed here only: it is the existing metered signal feed
strategy_templateYesAny operator kind; editable_params required. paid_read is refused
modelYesAny operator kind. paid_read is refused
vault_depositListed as a record onlyOperator kind vault. Real-money deposits are not offered: adopting or hiring it answers 409 wrong_status with "locked"

Only free listings can be adopted or hired in v1. Refusals: 403 not_on_allowlist when you do not pass the operator rule; 400 invalid_parameter with field (product_type, title, summary, ref_vault_id, editable_params, price_model, cited) for the rest. A seller who stops qualifying has their listings hidden on every read.

Provenance: recorded and cited#

Every listing and every operator entry carries two blocks that are never mixed:

  • recorded: computed by NightWatch from its own tables: days_live, adherence_headline (from the daily X-ray, insufficient when unknown), signals_30d, latest_signal_ts, vault_id, href. You cannot write it.
  • cited[]: claims you supply about yourself or elsewhere. They are shown as your claim and are not verified. Every item needs all four fields, and nothing else is allowed:
FieldRule
urlAn http or https link
source_nameUp to 80 characters
as_of_dateYYYY-MM-DD, not in the future
claim_textUp to 300 characters

Lists sort by recorded only: days live (more first), then signals in 30 days. Nothing cited is ever a sort key. GET /torii/gallery?sort= accepts only recorded.

Withdraw#

POST /torii/gallery/{id}/withdraw (the seller only). Writes a new status row; nothing is deleted. Answers {"id": 17, "status": "withdrawn"}, or {"id": 17, "status": "withdrawn", "already": true} on a repeat. Another account gets 403 not_owner; no such listing is 404 not_found.

What is not available#

  • Editing or deleting a published signal. Publish a superseding one.
  • Setting recorded or any ranking.
  • Selling anything but free listings, except a paid_read signal_subscription.
  • A real-money vault deposit.
  • A signal with a quantity in coins or dollars.

Next#

Follow a signal.