@wardx/server
@wardx/server is the Wardx ingest server.
The server receives POST /v1/sync. The server authenticates the project key. The server writes envelopes to a sink. The server aggregates frames into 1-minute windows per project. The server returns that project's Remote Config when the client version is not current.
HTTP is only the client path. Control, analysis, and visualization use MCP tools on the same process. There is no admin HTTP API.
Node.js 20 or later is required.
Install this package from npm. The published package does not include a config file. You must supply a JSON config file.
Install
npm install @wardx/server
import { createIngestServer, listen, startServer, loadServerConfig } from '@wardx/server';
The CLI name is wardx-server.
Config file
Pass the JSON file path as the CLI argument. The loader does not add a fallback for a missing path.
Required keys:
| Key | Description |
|---|---|
host |
Bind address. |
port |
Bind port. Use 0 to let the OS select a port. |
projectKeys |
Object that maps a project key to a project name. |
sink |
null, memory, or ndjson. |
ndjsonPath |
File path. Required when sink is ndjson. |
maxRequestBytes |
Maximum request body size. |
aggregateRetentionMinutes |
Retention of 1-minute windows. |
aggregateMaxSeriesPerMetric |
Maximum distinct dimension sets per metric name in one 1-minute window. Further series are dropped. |
memorySinkMaxEnvelopes |
Maximum envelopes in the memory sink. |
recentClientsMax |
Maximum recent client records kept per project. |
recentLogsMax |
Maximum recent log rows kept per project for MCP drill-down. |
projects |
Object keyed by project name. Each value is a Remote Config snapshot. |
Each projects.<name> object requires version, values, keyRoles, and experiments. keyRoles maps every config key to a list of role names, or ["*"] for every role that syncs. Optional catalog is MCP-only: a project description, roles (role name → { description } plus optional path and git; path is a local checkout, git is a repository URL), signals (name → text for Remote Config keys, metrics, and events), and experiments hypotheses. The catalog is not sent to SDKs. Mutating it does not bump configVersion. Ship a predefined catalog, or leave it empty and fill it during MCP onboarding. Both are valid. Every name in projectKeys must exist in projects.
Example wardx-server.json:
{
"host": "127.0.0.1",
"port": 8787,
"projectKeys": {
"dev_project_key": "demo"
},
"sink": "memory",
"ndjsonPath": "wardx-dev.ndjson",
"maxRequestBytes": 2097152,
"aggregateRetentionMinutes": 60,
"aggregateMaxSeriesPerMetric": 1000,
"memorySinkMaxEnvelopes": 10000,
"recentClientsMax": 100,
"recentLogsMax": 200,
"projects": {
"demo": {
"version": 1,
"values": {
"matchmaking.timeoutMs": 5000,
"message.delayMs": 1000
},
"keyRoles": {
"matchmaking.timeoutMs": ["game-server"],
"message.delayMs": ["client"]
},
"experiments": [],
"catalog": {
"description": "Demo chat app. Players send messages after a configurable delay.",
"signals": {
"message.delayMs": "Milliseconds to wait before sending a chat message",
"message.sent": "Chat messages that left the client after the delay"
},
"experiments": {}
}
}
}
}
HTTP interface
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/v1/sync |
X-Wardx-Key |
Ingest frames. Return that project's config if the version changed. |
GET |
/health |
None | Liveness. No telemetry. |
Sync request:
POST /v1/sync
Content-Type: application/json
Content-Encoding: gzip
X-Wardx-Key: <project key>
Delivery is at-most-once. The server does not persist a retry queue.
MCP interface
When Cursor or another MCP client spawns wardx-server, stdin is not a TTY. The process listens for sync and serves MCP on stdio. Logs go to stderr. Do not also run npm run server on the same port.
{
"mcpServers": {
"wardx": {
"command": "npx",
"args": ["wardx-server", "./wardx-server.json"]
}
}
}
Tools take a project name except list_projects. Read wardx://project/{name} for the same panorama as get_project_overview.
| Tool | Role |
|---|---|
list_projects |
Project names on this server. |
get_project_overview |
Description, onboarding gaps, Remote Config knobs, telemetry outcomes, previously proposed experiments, recent clients. |
set_project_description |
MCP-only product description. Does not bump configVersion. |
set_role_description |
MCP-only description of one client role. Does not bump configVersion. |
set_role_source |
Optional MCP-only path and/or git for one client role. Does not bump configVersion. |
set_signal |
Document one key, metric, or event. Does not bump configVersion. |
delete_signal |
Remove one catalog signal. Does not bump configVersion. |
get_config |
Remote Config snapshot sent to clients. |
set_config_value |
Set one key and the roles that receive it. Bumps configVersion. |
delete_config_value |
Delete one key. Bumps configVersion. |
list_experiments |
Previously proposed experiments, with hypothesis when set. |
upsert_experiment |
Propose or replace an experiment over existing Remote Config keys. Optional hypothesis stays on the server. Bumps configVersion. |
set_experiment_enabled |
Enable or disable an experiment. Bumps configVersion. |
get_aggregates |
1-minute windows with catalog legends. Optional names, from, to. |
get_recent_logs |
Recent log rows, newest first. Optional level, message, attrs, limit. |
analyze_experiment |
Definition, hypothesis, exposures, goals, goalSum, goalMean by variant, primaryMetric fleet total. |
The SDK sends names with no descriptions. Meaning lives in the catalog. A predefined catalog in the config file can make onboarding.complete true on the first read. If it is false, the agent asks only about missingDescription and the listed undescribed knobs and outcomes, then writes answers with set_project_description and set_signal. It does not invent descriptions, does not re-ask names that already have a legend, and does not propose experiments until onboarding.complete is true.
After that, an agent reads the overview, proposes experiments on the listed knobs, and can later list or analyze those proposals. variant.values may only contain keys that already exist in Remote Config.
If the process loaded a config file, mutations rewrite that file so they survive a restart. Catalog edits persist without incrementing version. Config and experiment edits increment version. Clients compare the number only.
Use case 1: Start the ingest server from npm
When: You need a local or development ingest endpoint for the wardx SDK.
Objective: Listen on a host and port with your config file.
Procedure
- Install
@wardx/server. - Write a JSON config file. See the example above.
- Pass the file path to the CLI.
npm install @wardx/server
npx wardx-server ./wardx-server.json
The process writes to stderr:
wardx ingest listening on 8787
- Point the Node.js SDK to the server.
import { createWardx } from 'wardx';
const wardx = createWardx({
endpoint: 'http://127.0.0.1:8787',
projectKey: 'dev_project_key',
project: 'demo',
role: 'client',
appVersion: '0.1.0',
environment: 'development'
});
The project key in createWardx must exist in projectKeys. The project name must match the mapped value.
Use case 2: Start the server in a process
When: You embed the ingest server in a test or in your process.
Objective: Create the server, listen, then close.
import { createIngestServer, listen, loadServerConfig } from '@wardx/server';
const config = loadServerConfig('./wardx-server.json');
const server = createIngestServer(config);
const address = await listen(server, config.port, config.host);
console.log(`listening on ${address.port}`);
server.close();
startServer(config) creates the server and listens. The CLI loads the path from process.argv[2] and calls startServer. When stdin is not a TTY, the CLI also starts MCP stdio.
server.wardx contains config, registry, control, and sink. Tests can use these objects.
You can pass a config object. You do not need a file:
import { createIngestServer, listen } from '@wardx/server';
const server = createIngestServer({
host: '127.0.0.1',
port: 0,
projectKeys: { 'test-key': 'demo' },
sink: 'memory',
maxRequestBytes: 2097152,
aggregateRetentionMinutes: 60,
aggregateMaxSeriesPerMetric: 1000,
memorySinkMaxEnvelopes: 1000,
recentClientsMax: 50,
recentLogsMax: 100,
projects: {
demo: {
version: 1,
values: { 'message.delayMs': 1000 },
keyRoles: { 'message.delayMs': ['client'] },
experiments: []
}
}
});
const address = await listen(server, 0, '127.0.0.1');
Use case 3: Ingest frames from an SDK
When: A Wardx client sends a gzip JSON envelope.
Objective: Authenticate, validate, write the sink, aggregate for that project, and respond.
Ingest order:
- Read
X-Wardx-Key. Map the key to a project name. - Read the body. Reject the body if the size is above
maxRequestBytes. - Gunzip the body when
Content-Encodingisgzip. - Parse JSON. Validate the envelope.
- Reject the envelope if
body.projectdoes not match the key. - Write the envelope to the sink.
- Add the frames to that project's 1-minute aggregator.
- Record the client in that project's recent-client ring.
- Push log rows into that project's recent-log ring.
- If
configVersionis not current for that project, include that role's snapshot in the response.
Same version:
{ "ok": true, "serverTime": 1787221135102, "configVersion": 12 }
Newer snapshot:
{
"ok": true,
"serverTime": 1787221135102,
"configVersion": 13,
"config": {
"values": { "message.delayMs": 1000 },
"experiments": []
}
}
The server stores one configVersion per project and caches a JSON view per role. It does not rebuild config JSON per instance.
Error examples:
| Status | Condition |
|---|---|
401 |
Missing or unknown X-Wardx-Key. |
400 |
Invalid gzip, invalid JSON, invalid envelope, or project mismatch. |
413 |
Body larger than maxRequestBytes. |
Use case 4: Change Remote Config while clients sync
When: You change a value or an experiment. Clients must get the new snapshot.
Objective: Use MCP. Do not call HTTP.
Ask the agent to set a key or upsert an experiment on the project. Equivalent in-process call:
server.wardx.control.setValue('demo', 'message.delayMs', 400, ['client']);
server.wardx.control.upsertExperiment('demo', {
id: 'message-delay-v1',
enabled: true,
allocation: 1,
salt: '3ad8f9',
primaryMetric: 'message.sent',
roles: ['client'],
hypothesis: 'Shorter delay increases messages sent',
variants: [
{ key: 'control', weight: 50, values: { 'message.delayMs': 1000 } },
{ key: 'fast', weight: 50, values: { 'message.delayMs': 400 } }
]
});
The next client sync that sends an older configVersion receives config in the response. The Node.js SDK applies that snapshot in memory.
Assignment runs on the client. The server does not map users to variants. The SDK uses identify() or a per-call subjectId. A read with no subject returns the Remote Config value and does not emit experiment.exposure. Keep the salt when replacing the same experiment id. Changing the salt redistributes the population.
Use case 5: Read telemetry and analyze an experiment
When: You inspect counters, events, clients, or an A/B test.
Objective: Use MCP tools get_aggregates, get_recent_logs, get_project_overview, and analyze_experiment. Start from the overview so knobs and outcomes have catalog legends.
The aggregator merges frames by minute per project. The aggregator keeps windows for aggregateRetentionMinutes. Counters in a window are sums of window deltas. A gauge in a window is the last value by timestamp. Events count by name and role. Event attrs are not series. experiment.exposure and experiment.goal roll up by experiment and variant. Each metric name keeps at most aggregateMaxSeriesPerMetric distinct dimension sets per window. Extra series increment cardinalityDropped on that window.
A volume funnel is that comparison: pass the step names in names and compare counters (or eventNames) in one window and role. That is how often each step fired. The aggregator does not store sequences, unique subjects, or time between steps. Production sink: "null" discards envelopes after ingest, so there is no later join on sessionId. Instrument the steps in the SDK. See wardx use case 7.
get_recent_logs reads a per-project ring of recent log rows (recentLogsMax). It does not search history. Filter by level, message, and attrs (exact match on the listed keys). A stack or a provider code is just another attr.
This is in-memory development aggregation. This is not a query API for production analytics.
analyze_experiment rolls every experiment.goal for that experiment into goals and goalSum. goalMean is goalSum / goals. For a duration goal, pass the milliseconds as value once per ended session. Do not mix a duration value with a value: 1 conversion on the same experiment. primaryMetric.total is the fleet counter of that name. It is not split by variant.
Use case 6: Select a sink
When: You choose where envelopes go.
Objective: Set sink in the config file.
| Value | Behavior |
|---|---|
null |
Discard envelopes. Production and ingest benchmarks. MCP does not read this store. |
memory |
Keep envelopes in an array. Drop the oldest envelope above memorySinkMaxEnvelopes. Local debugging. |
ndjson |
Append one JSON object per line to ndjsonPath. Local debugging. |
Programmatic sinks:
import { NullSink, MemorySink, NdjsonSink } from '@wardx/server';
const nullSink = new NullSink();
const memorySink = new MemorySink({ memorySinkMaxEnvelopes: 10000 });
const ndjsonSink = new NdjsonSink({ ndjsonPath: 'wardx-dev.ndjson' });
createIngestServer constructs the sink from config.sink. You do not pass a sink object to createIngestServer.
Use case 7: Propose a difficulty experiment to increase session time
When: An agent should hypothesise that changing level difficulty will increase play time.
Objective: Use MCP. Do not call HTTP. The app must already emit session duration (see wardx use case 14) and read the difficulty keys with config.get (see wardx use case 15).
server.wardx.control.upsertExperiment('demo', {
id: 'difficulty-v1',
enabled: true,
allocation: 1,
salt: '3ad8f9',
primaryMetric: 'session.time_ms',
roles: ['unity'],
hypothesis: 'Lower HP on level 3 increases session duration',
variants: [
{ key: 'control', weight: 50, values: { 'level.3.enemyHp': 100 } },
{ key: 'easy', weight: 50, values: { 'level.3.enemyHp': 70 } }
]
});
Equivalent MCP tool: upsert_experiment. Later analyze_experiment with that id.
Read it this way:
- Overview first. Refuse until
onboarding.complete. Confirm the knobs exist and which roles receive them. get_aggregateswithlevel.start,level.fail,level.complete, andsession.time_ms. The funnel is the difficulty signal.session.time_msis fleet play time in the window.analyze_experiment: comparegoalMeanby variant. That mean is theexperiment.goalvalue the SDK sent (session duration in ms).primaryMetric.totalis fleetsession.time_ms. It is not split by variant.- If exposures stay at zero, the app is reading the knob with no subject.
Wardx does not rewrite level files. The experiment changes Remote Config. Clients apply the snapshot on the next sync.
Use case 8: Drill an error so an agent can edit the role source
When: A role is logging failures and you want an agent to open the file that threw.
Objective: MCP returns the recent row. Wardx does not patch application source. The agent uses path or git on that role, plus its own file permissions.
const rows = executeTool(server.wardx.control, 'get_recent_logs', {
project: 'demo',
level: 'error',
message: 'payment_failed',
limit: 5
});
rows.logs[].attrs.stack is a string when the SDK sent one. Filter with attrs: { code: 'timeout' } for an exact match.
If the role has path (a local checkout) or git (a repository URL), the agent inspects or edits that surface. Set those fields with set_role_source when the user supplies them, or ship them in the catalog. Do not invent them.
The ring is recent only (recentLogsMax). It is not a history search.
Exports
| Export | Function |
|---|---|
createIngestServer(config) |
Creates an http.Server. |
listen(server, port, host) |
Listens and returns server.address(). |
startServer(config) |
Creates the server and listens. |
loadServerConfig(path) |
Reads and validates a JSON file. |
ControlService |
In-process control for config, experiments, and aggregates. |
executeTool |
MCP tool handlers used by tests and stdio. |
FrameAggregator |
1-minute in-memory aggregation. |
ConfigRepository |
Stores one config snapshot. |
NullSink, MemorySink, NdjsonSink |
Envelope destinations. |
Related packages
- Node.js SDK:
wardx - Engine:
@wardx/core