- 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:
1. **The runtime** (`src/`) — connects to the source, transforms incoming events into `W2ASignal`, calls `ctx.emit()`.
2. **`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.
```json
{
"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`
```typescript
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,
// SDK / install-tooling hint — NOT part of the wire protocol.
// `{ type: "none" }` when no credentials; `{ type: "api_key", fields: [...] }`
// with one entry per credential prompt. See @world2agent/sdk for shape.
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.
```typescript
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(/* source event args */): W2ASignal {
return createSignal(SENSOR_META, {
source: { user_identity: "..." },
event: {
type: "<domain>.<entity>.<action>",
summary: "...", // Phase 2 template
},
source_event: {
schema: {
/* JSON Schema draft-07 describing `data`.
Every property SHOULD carry a `description` so the payload
is self-describing without sensor-specific knowledge. */
},
data: { /* original event fields */ },
},
attachments: [
{
type: "inline", // or "reference"
mime_type: "text/plain",
description: "...", // required; AI-readable
data: "...", // or `uri: "..."` for type: "reference"
},
],
});
}
```
### `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.
```typescript
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;
}
// 1. Read body. 2. Verify HMAC via timingSafeEqual.
// 3. JSON.parse. 4. transform<Name>Event → ctx.emit(signal).
// 5. res.writeHead(200).end().
});
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.
```typescript
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`.
```typescript
import { createPollLoop } from "@world2agent/sdk/helpers";
export async function start(ctx: SensorContext<<Name>Config>): Promise<CleanupFn> {
const { cleanup } = createPollLoop({
Voir sur GitHub