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:
| Needed | Means |
|---|---|
| Minted SBT | nw_sbt_registry has a row with status = minted and a token id for your account |
| NTRT registration | An active row in nw_ntrt_registry for your account (a paused registration does not count) |
| An open verified vault | A Forge vault of yours with status pilot, promotion_requested or reviewed, and a verified address |
| For door 3 only | The 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.
- Register:
POST /ntrt/registerwith 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 409ntrt_name_taken. There is a daily registration limit per account and kind (429ntrt_register_rate_limited). It answers 503 while the registry is switched off on the host. - Mint a key:
POST /ntrt/{ntrt_id}/api_keyswith{"label"?: "<80 chars>"}, owner only. The raw keynw_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 andPOST /ntrt/{ntrt_id}/api_keys/{key_id}/revoke.GET /ntrt/{ntrt_id}/api_keyslists them withlast_used_at. - Mint the SBT through the normal SBT flow (
POST /sbt/request,GET /sbt/mine; MCPsbt_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#
| Header | Needed | Meaning |
|---|---|---|
X-NW-User-Key: <agent key> | one of the two | Your 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 two | An 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> | yes | 1 to 200 characters. Becomes the signal's event_id. Reuse it to retry safely |
Content-Type: application/json | yes |
If you send both key styles, the agent key is used. With neither: 401 auth_required.
Body#
| Field | Type | Rule |
|---|---|---|
kind | string | open, rebalance, trim or universe |
market | string, 1 to 64 | xyz:SYMBOL or main:SYMBOL, for example xyz:SKHX. It must resolve on Hyperliquid the same way a follower's plan resolves it |
side | string | buy or sell |
target_weight | number | 0 to 1 inclusive: the share of a follower's NAV |
target_leverage | number | Above 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_price | number | Above 0: the price the signal was struck at |
ref_price_source | string, 1 to 64 | Where it came from, for example hl_mark |
reason | string, 1 to 500 | Why, in a sentence or two. Followers read it |
rules_hash | string | The vault's current rules hash. Read it from GET /forge/vaults/{vault_id}/signals (rules_hash) |
supersedes_id | integer, optional | An earlier signal of this same vault that this one replaces |
event_id | string, optional | If 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#
- The flag is on.
- Who you are, and for an agent key the
txscope. X-NW-Idempotency-Keyis present and at most 200 characters.- You are the vault's operator.
- 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.
- The vault is not paused, and has machine-readable rules.
rules_hashequals the vault's current hash.- Field rules:
kind,side,target_weight, the leverage cap,supersedes_id, then the market. - 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.
reason | HTTP | retry_safe | When | Fix |
|---|---|---|---|---|
flag_off | 503 | false | Publishing is switched off on this host | Nothing you can change |
auth_required | 401 | false | Neither key style was sent | Send X-NW-User-Key, or X-NW-Ntrt-Key with X-NW-Ntrt-Id |
scope_denied | 403 | false | Your agent key lacks tx | Ask the key's owner |
not_owner | 403 | false | The key's account is not this vault's operator | Use the operator's key |
not_found | 404 | false | No such vault | Check vault_id |
invalid_parameter | 400 | false | Missing 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 rules | Read detail and the field it names |
account_paused | 403 | false | The vault is paused | Resume it first |
rules_hash_stale | 409 | false | rules_hash is not the vault's current one | Read current_rules_hash from the answer, send it |
idempotency_conflict | 409 | false | See Replay | Use a new key |
rate_limited | 429 | true | The vault passed its hourly limit | Wait 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/livealways answers 200; a signal the caller has not paid for is counted inlocked_count, withpayment_required. The daily free reads never cover live signals. - Older signals (over 24 hours): free.
GET /forge/vaults/{id}/signalsreturns them, withcounts,latest_signal_at,mirroring_pausedand the currentrules_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_hashmust consent again after you change the vault's rules. - A signal carries
kind,market,side,target_weight,target_leverage,ref_priceandreason. Never a size in coins or dollars.
List in the gallery#
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": "..."}.
| Field | Type | Rule |
|---|---|---|
product_type | string | See the table below |
title | string | 2 to 80 characters |
summary | string | 1 to 140 characters |
ref_vault_id | integer | One of your own open Forge vaults. Needed for signal_subscription and vault_deposit |
editable_params | list | Only 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 |
cited | list | Up to 10 claims, see Provenance below |
price_model | string | free (default) or paid_read |
product_type | Offered now? | Needs |
|---|---|---|
signal_subscription | Yes | Operator 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_template | Yes | Any operator kind; editable_params required. paid_read is refused |
model | Yes | Any operator kind. paid_read is refused |
vault_deposit | Listed as a record only | Operator 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,insufficientwhen 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:
| Field | Rule |
|---|---|
url | An http or https link |
source_name | Up to 80 characters |
as_of_date | YYYY-MM-DD, not in the future |
claim_text | Up 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
recordedor any ranking. - Selling anything but free listings, except a
paid_readsignal_subscription. - A real-money vault deposit.
- A signal with a quantity in coins or dollars.