| name | build-w2a-sensor |
| description | Use this skill when the user asks to "build a W2A sensor", "create a world2agent sensor", "add a sensor for <source>", or otherwise wants to emit signals into the World2Agent protocol from a new data source. Guides discovery → design → scaffold → install recipe → test. |
| version | 0.1.0 |
| user-invocable | true |
| disable-model-invocation | false |
Build a World2Agent Sensor
You are guiding a developer through designing and building a W2A sensor — a package that subscribes to an external source and emits W2ASignal per the World2Agent protocol.
Protocol reference: https://github.com/machinepulse-ai/world2agent — the schema under schema/0.1/ is authoritative for every field shape. If anything in this skill contradicts the schema, the schema wins.
Your first job is discovery, not code. Sensors that skip discovery ship either too much config (tuning knobs no one touches) or too little signal quality (vague summaries, wrong event types, missing install recipe). Both are painful to fix later.
A sensor package has two deliverables:
- The runtime (
src/) — connects to the source, transforms incoming events into W2ASignal, calls ctx.emit().
SETUP.md — the install recipe. When the consumer agent runs the host's sensor-add flow (e.g. /world2agent:sensor-add <package> in Claude Code), it reads this file, runs the Q&A defined inside, writes the sensor's entry into ~/.world2agent/config.json, and writes a per-user handler skill into the agent runtime's skills directory. The handler is generated by the install flow, not shipped in the package.
Do not scaffold until Phase 1 and Phase 2 are settled.
Phase 1 — Interrogate the source
Ask these conversationally (batch in one message, don't serialize). Adapt to what the user already said.
1. What does the sensor connect to?
Name the source platform and give the user-facing "why this matters". This drives source_type, package naming, and what domain namespace the event types use.
2. How do events reach the sensor?
Pick the highest available option — do not default to polling if a push channel exists.
| Mechanism | Use when | Notes |
|---|
| Official push (webhook / SSE / WebSocket) | The source offers it | Preferred: seconds latency, no wasted calls, no cursor drift |
| Official API polling | No push offered | Persist a cursor via ctx.store |
| Schedule-triggered (cron) | Source is inherently periodic (digests, daily summaries) | Time itself is the trigger |
| File-system watch / scraping | No official API at all | Last resort — brittle and hard to test |
3. What event types will it emit?
List every triple it will emit as domain.entity.action (open namespace — you coin them). Examples: messaging.message.received, repo.issue.created, market.quote.threshold_crossed. These strings are your sensor's public contract — consumers pattern-match against them, so stability matters.
domain is the abstract source space (messaging, repo, market, calendar), not the platform name. The platform identity already lives in source.source_type ("slack", "github", "jira"). A Slack sensor emits messaging.message.mentioned, not slack.message.mentioned — that's what lets a consumer write handler.on("messaging.message.mentioned") once and match @-events from Slack, Discord, Lark, and Teams alike. action is a verb in past tense (mentioned, opened, received, entered), not a base form or gerund (mention ❌, opening ❌).
4. What config does the sensor need?
Credentials (if any) + the smallest number of behavioural fields to make end-to-end work. Defaults beat required fields. Five is already a lot. Save tuning knobs (max_retries, timeout_ms, batch_size) for when a real user asks.
If the user can't answer #3 crisply, stop and work on that — vague event taxonomies produce vague summaries, and event.summary is what agents read first.
Phase 2 — Design the signal
For each event type, pin down the signal shape before writing code.
event.summary
The summary is the soul of the signal — an agent reading only this line must be able to decide whether and how to act. Use the pattern [Actor] [Action] [Object] in [Context]; [Impact]. Sketch one summary template per event type:
repo.pull_request.opened → "{author} opened PR #{num} in {repo}: {title}; touches {files_count} files across {areas}, needs review before {deadline}."
Bad summaries: "new message", "PR update", "price moved". Reject and rewrite.
event.type vs source_event vs attachments
Three channels, three jobs. Keep them separate:
| Field | Carries | Example |
|---|
event | Normalized cross-source classification — type, occurred_at, summary | type: "messaging.message.mentioned", summary text |
source_event | Self-describing structured data from the source: { schema, data } with JSON Schema draft-07 for schema | IDs, numbers, booleans, enums the graph/agent will reason over |
attachments | Unstructured content blobs (message bodies, diffs, images, audio) | Text body of the message, PDF file, screenshot |
Every property in source_event.schema SHOULD carry a description — that's what makes the payload self-describing. A schema that only declares types ({ "type": "integer" }) leaves the consumer guessing what the value means; with a description ({ "type": "integer", "description": "Stars gained in the last 24 hours" }) an agent can reason about the data without sensor-specific knowledge.
Never put structured machine data in an attachment. Never put large blobs in source_event.data.
Attachment choice: inline vs reference
Attachment is a tagged union:
{ type: "inline", mime_type, description, data } — content embedded as a string (UTF-8 for text, base64 for binary).
{ type: "reference", mime_type, description, uri } — external pointer, consumers fetch on demand.
Prefer reference for anything larger than a few KiB, already-addressable URLs, or content the consumer may not need. description is required on both and must be AI-readable — it's how a consumer decides whether to open the attachment.
Phase 3 — Scaffold the package
Layout:
<package-dir>/
├── package.json
├── tsconfig.json
├── SETUP.md — install/Q&A recipe (Phase 4)
├── README.md — one-pager for package registries
└── src/
├── index.ts — defineSensor + re-exports
├── start.ts — main loop (pattern from Phase 1 #2)
├── transform.ts — source event → W2ASignal
├── types.ts — config schema + source types
└── bin.ts — CLI entry point
package.json
Substitute <package-name> and <bin-name> with whatever coordinates you publish under. The keywords array is the discoverability contract — fill in the real source_type decided in Phase 1, do not leave the placeholder. npm search w2a-sensor and SensorHub indexing both rely on these keywords.
{
"name": "<package-name>",
"version": "0.1.0",
"description": "W2A sensor — <one-line description>",
"keywords": [
"world2agent",
"w2a",
"w2a-sensor",
"sensor",
"agent",
"<source_type>"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"bin": { "<bin-name>": "./dist/bin.js" },
"scripts": {
"start": "node dist/bin.js",
"build": "tsc --build",
"clean": "rm -rf dist *.tsbuildinfo",
"postpublish": "npx -y @world2agent/notify-hub || true"
},
"dependencies": {
"@world2agent/sdk": "^0.1.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.8.0"
},
"files": ["dist", "SETUP.md", "README.md"],
"w2a": {
"type": "sensor",
"source_type": "<source_type>",
"signals": ["<domain>.<entity>.<action>"],
"setup": "./SETUP.md"
}
}
The first five keywords are mandatory for every W2A sensor — they are how consumers discover sensors via npm search. The last entry is the source_type decided in Phase 1 (e.g. "github", "hackernews", "slack"). Add additional source-specific tags ("trending", "oauth", etc.) only if they would actually help discovery.
The w2a block is tooling metadata the install CLI reads; it is not part of the wire protocol.
src/index.ts
import { defineSensor } from "@world2agent/sdk/sensor";
import { <name>ConfigSchema } from "./types.js";
import { start } from "./start.js";
export default defineSensor({
id: "<package-name>",
version: "0.1.0",
source_type: "<source_type>",
configSchema: <name>ConfigSchema,
auth: { type: "none" },
start,
});
export { transform<Name>Event } from "./transform.js";
export { <name>ConfigSchema } from "./types.js";
export type { <Name>Config } from "./types.js";
src/transform.ts
createSignal() auto-injects source.package from the meta you pass — never set it manually.
import { createSignal, type W2ASignal } from "@world2agent/sdk";
const SENSOR_META = {
id: "<package-name>",
version: "0.1.0",
source_type: "<source_type>",
} as const;
export function transform<Name>Event(): W2ASignal {
return createSignal(SENSOR_META, {
source: { user_identity: "..." },
event: {
type: "<domain>.<entity>.<action>",
summary: "...",
},
source_event: {
schema: {
},
data: { },
},
attachments: [
{
type: "inline",
mime_type: "text/plain",
description: "...",
data: "...",
},
],
});
}
src/start.ts — pick the pattern from Phase 1 #2
The sections below are skeletons. Fill in the source-specific parts.
Webhook (push) — serve an HTTP endpoint, verify signature, transform, emit.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { SensorContext, CleanupFn } from "@world2agent/sdk";
import { transform<Name>Event } from "./transform.js";
import type { <Name>Config } from "./types.js";
export async function start(ctx: SensorContext<<Name>Config>): Promise<CleanupFn> {
const server = createServer(async (req, res) => {
if (req.method !== "POST" || req.url !== ctx.config.path) {
res.writeHead(404).end(); return;
}
});
await new Promise<void>(r => server.listen(ctx.config.port, r));
ctx.reportHealth("ok");
return () => new Promise<void>((res, rej) =>
server.close(err => err ? rej(err) : res()));
}
WebSocket (push) — connect, auto-reconnect on close, emit per message.
export async function start(ctx: SensorContext<<Name>Config>): Promise<CleanupFn> {
let reconnect = true;
let ws: WebSocket | undefined;
function connect() {
ws = new WebSocket(ctx.config.ws_url);
ws.addEventListener("open", () => ctx.reportHealth("ok"));
ws.addEventListener("message", async ev => {
await ctx.emit(transform<Name>Event(JSON.parse(String(ev.data))));
});
ws.addEventListener("close", () => {
ctx.reportHealth("degraded", "disconnected");
if (reconnect) setTimeout(connect, 5000);
});
}
connect();
return () => { reconnect = false; ws?.close(); };
}
Polling (fallback) — @world2agent/sdk/helpers ships createPollLoop; persist cursor via ctx.store.
import { createPollLoop } from "@world2agent/sdk/helpers";
export async function start(ctx: SensorContext<<Name>Config>): Promise<CleanupFn> {
const { cleanup } = createPollLoop({
items: [ctx.config.api_url],
intervalSeconds: ctx.config.poll_interval_seconds,
logger: ctx.logger,
poll: async (url) => {
const cursor = await ctx.store?.get("cursor");
},
});
ctx.reportHealth("ok");
return cleanup;
}
src/bin.ts
#!/usr/bin/env node
import { run } from "@world2agent/sdk/consumer";
import sensor from "./index.js";
run(sensor);
src/types.ts
import { z } from "zod";
export const <name>ConfigSchema = z.object({
});
export type <Name>Config = z.infer<typeof <name>ConfigSchema>;
Phase 4 — Author SETUP.md (the install experience)
SETUP.md is what the consumer agent reads when installing a sensor (via /world2agent:sensor-add <package> in Claude Code, or the equivalent skill/tool in Hermes / OpenClaw). It must:
- Describe the sensor in one paragraph.
- Declare every config parameter in a table (written to
~/.world2agent/config.json).
- List the Q&A questions to ask the user at install time.
- Provide a handler-skill template with
[USER_X] placeholders the Q&A answers fill in.
- Tell the agent where to write outputs.
SETUP.md is English-only. The consumer agent reads English, then translates questions into the user's language at runtime. Don't mix languages inside SETUP.md.
Question economy
Every question costs user attention. Cut hard.
- ≤ 5 questions total. More than that, your design is wrong.
- No positive + negative framing of the same preference. "What do you care about?" is enough — don't also ask "What to skip?".
- Default every tuning knob. Poll intervals, max_results, debounce, reconnect limits — default them. Document the default in the Configuration Parameters table. Only ask if 50%+ of users genuinely want to override.
- Bundle credentials into one prompt. "Paste credentials (app_id, app_secret, token):" not three turns.
- Bundle per-type behaviour. "For DM / @mention / group — for each: reply / log / ignore?" is one question with a matrix answer, not three.
- Every question must drive a concrete artifact — a
config.json field or a [USER_X] placeholder. If the answer doesn't change output, delete it.
Smell test: if the user is going to say "just use defaults", your Q&A is too long. Cut until "just use defaults" is already the default path.
SETUP.md template
# <Human Name> Sensor Setup
<One paragraph: what this sensor connects, what events it surfaces, and the coarse-filter vs. semantic-judgement split.>
## Configuration Parameters (written to `~/.world2agent/config.json`)
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `<field>` | <type> | <Yes/No> | <default or —> | <one-line why> |
## Questions to Ask
### 1. Config questions (populate the sensor config)
<For each config field the user must provide: the natural-language question. Bundle credentials into one prompt.>
### 2. Semantic preferences (populate the handler skill)
<Questions whose answers fill the [USER_X] placeholders in the handler skill template — topics, thresholds, reply style, what to ignore. Prefer multiple-choice.>
## Output
### 1. Write `~/.world2agent/config.json`
\`\`\`json
{
"sensors": [
{
"package": "<package-name>",
"config": { /* answers from §1 */ },
"skills": ["<absolute path to the handler skill's directory from §2>"]
}
]
}
\`\`\`
> `skills` is a REQUIRED array of absolute **directory paths** (not file paths) for every handler skill written in §2. The channel's removal flow (`/world2agent:sensor-remove` in Claude Code, etc.) reads this to clean up; without it the cleaner misses non-default locations.
### 2. Write the handler skill
Write the handler to the agent runtime's skills directory. The skill filename and frontmatter `name:` MUST equal `packageToSkillId(<package-name>)` — e.g. `@your-scope/sensor-slack` → `your-scope-sensor-slack` — so the channel's `Use skill: <id>` directive routes correctly.
Template:
\`\`\`markdown
---
name: <packageToSkillId(<package-name>)>
user-invocable: false
description: Handle <domain> signals (<event-type-pattern>). <One-line user-context placeholder>: [USER_TOPICS].
---
# <Human Name> Signal Handler
## User Preferences
[USER_PREFERENCES]
## Signal Shape
- `event.type`: `<domain>.<entity>.<action>`
- `event.summary`: <what's in the summary>
- `source_event.data`: <key structured fields the handler reads>
- `attachments[0]`: <what the attachment carries — `.data` for inline, `.uri` for reference>
## Priority & Handling
- `<event.type>` urgency [immediate | attention | informational]: [USER_ACTION]
- Not relevant: [USER_SKIP_POLICY]
\`\`\`
> `user-invocable: false` prevents the handler from surfacing as a slash command in agent runtimes that expose user-invocable skills that way.
## Before writing
1. Show the proposed `config.json` and handler skill to the user for confirmation.
2. Resolve the handler-skill directory (runtime-dependent — consult the agent's skills discovery rules).
3. Write the handler skill, record its directory's absolute path, and write that path into the `skills` array of `config.json`.
Phase 5 — Test and iterate
pnpm install (or your package manager's equivalent) to link the package.
pnpm build — verify it compiles clean.
- Use
createTestHarness() from @world2agent/sdk/testing to drive transform<Name>Event with fixture source events and assert the resulting W2ASignal — event.summary, event.type, source_event.data, and attachments should all look right without running the live source.
- Dry-run the install flow in your target host — e.g.
/world2agent:sensor-add <package-name> in Claude Code — and walk through the Q&A. Check that every generated [USER_X] placeholder in the handler skill is filled meaningfully.
- Run against the real source. Confirm
summary reads well to a human without looking at source_event.
If the summary is unreadable, your Phase 2 work wasn't thorough — go back.
Phase 6 — Offer to publish
Once the sensor builds clean, the harness tests pass, and the install flow produces a sensible handler skill, the package works locally — but a sensor that lives only on the author's disk doesn't participate in the W2A ecosystem. Other users who want to perceive the same source have no way to find or install it.
Do not publish unprompted. Ask the user first, in plain language, something like:
"The sensor is working locally. Want to publish it to npm so others with the same need can install it directly? It'll be discoverable via npm search w2a-sensor and listable in SensorHub once that launches. If you'd rather keep it private for now, that's fine — you can publish later."
If the user declines, stop here. Note that they can revisit publishing any time; do not nag.
If the user agrees, walk them through the checks below before running the publish command — do not run npm publish silently.
-
Pre-flight the manifest. Confirm package.json is publish-ready:
name — final coordinates (scoped names like @your-scope/sensor-<source> are fine and recommended; they avoid name squatting).
version — start at 0.1.0; bump per semver on every subsequent publish.
description, keywords (the five mandatory W2A tags + <source_type>), license, repository, homepage, author — registries and SensorHub surface these.
files — already restricts to dist, SETUP.md, README.md; double-check no secrets or fixtures leak in. Run npm pack --dry-run to preview the tarball contents.
w2a block — source_type and signals reflect Phase 1 / Phase 2 final answers, not placeholders.
-
Verify the build artifact ships. pnpm build (or equivalent) and confirm dist/ exists with index.js, index.d.ts, and bin.js. The bin file should start with the #!/usr/bin/env node shebang so npx <bin-name> works after install.
-
Login + dry-run. npm whoami to confirm the right account; npm publish --dry-run to preview what would upload. Read the file list out loud — if anything looks wrong, fix files / .npmignore rather than YOLO-ing the publish.
-
Publish.
- Public scoped package:
npm publish --access public (scoped packages default to private; the flag is required on first publish).
- Unscoped package:
npm publish.
- 2FA-protected account: have the user ready with their OTP.
-
Smoke-test the published artifact. In a scratch directory: npm view <package-name> to confirm the registry sees it, then dry-run the install flow against the published name (/world2agent:sensor-add <package-name> in Claude Code) — not the local path — to confirm SETUP.md is in the tarball and the Q&A still works end-to-end.
-
Tag the release. git tag v0.1.0 && git push --tags so the npm version maps back to a commit.
-
SensorHub auto-registers. The scaffolded postpublish script POSTs {package_name, version} to https://world2agent.ai/api/v1/sensors/notify immediately after npm publish succeeds; the listing typically appears at https://world2agent.ai/hub/<slug> within ~30 seconds. The CLI prints either listed: <name>@<ver> → <hub_url> (verified) or queued: ... (verifying via npm; up to 24h) (npm CDN propagation in progress).
Three fallback layers cover failure modes: server-side retry up to 8 attempts over 24h handles npm propagation delay; a daily discoverNewSensors cron sweeps npm search keywords:w2a-sensor for anything the hook missed; and the manual https://world2agent.ai/hub/submit form remains available for opt-in retroactive listing. Treat the auto path as the default — only mention /hub/submit if the user explicitly asks to list manually or the auto path errored without recovery.
If the user already has a custom postpublish script, append && npx -y @world2agent/notify-hub || true to it. The trailing || true is load-bearing — it ensures a hub outage never marks the publish as failed in set -e CI.
If publishing fails for a reason you don't immediately understand (E403, name conflict, 2FA loop), stop and surface the error to the user — do not retry blindly with version bumps.
Rules
- Protocol is authoritative. For any field shape or naming rule, https://github.com/machinepulse-ai/world2agent wins. This skill mirrors it; if the two drift, fix the skill.
- Source access: prefer push. Webhook / SSE / WebSocket before polling. Polling before scraping. Don't default to polling if an official push channel exists.
- Config stays small. v0.1 ≤ 5 fields, no speculative knobs. Every field has a one-line "why" in both
types.ts and SETUP.md.
- SETUP.md is the install experience. Generic SETUP.md = useless install. Q&A questions and skill template must reflect Phase 1 + Phase 2 answers verbatim.
- SETUP.md is English-only. The agent translates to the user's language at runtime.
event.type follows domain.entity.action. Open namespace — sensors coin their own triples; the set a sensor emits is part of its public contract.
event.summary is the soul of the signal. Actor–Action–Object–Context–Impact. An agent reading only the summary must be able to decide whether and how to act. Never write generic summaries.
- Attachment shape. Tagged union:
{type: "inline", mime_type, description, data} or {type: "reference", mime_type, description, uri}. description is required on both and AI-readable. Never put structured machine data in an attachment — that lives in source_event.data.
source_event vs event. event = normalized cross-source classification. source_event = self-describing original platform payload. Both sit next to attachments at the top of W2ASignal.
source.package is auto-injected by createSignal() from SENSOR_META.id — never set manually.
- Handler skill is produced by install, not shipped. The package ships
SETUP.md only; the Q&A writes the per-user handler at install time.
- Handler frontmatter includes
user-invocable: false — the handler is auto-invoked by signal routing, not user-triggered.
SensorStore is string-only — JSON.stringify / JSON.parse for structured data.
- Always return a cleanup function from
start().
- Call
ctx.reportHealth() — ok on start, degraded on transient error, error on fatal.
postpublish auto-notifies SensorHub. Scaffolded package.json includes postpublish: npx -y @world2agent/notify-hub || true, which calls https://world2agent.ai/api/v1/sensors/notify so the package lists itself within ~30s of npm publish. Failure-safe (|| true) and idempotent. Removing this line falls back to the daily discoverNewSensors cron + manual /hub/submit.
Links