skillscript-runtime 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,320 @@
1
+ /**
2
+ * HttpWebhookAgentConnector — example AgentConnector for HTTP-webhook substrates.
3
+ *
4
+ * This is the canonical worked example for adopter agents writing their own
5
+ * AgentConnector against an HTTP substrate. It's intentionally readable + small,
6
+ * not feature-complete. Adopters fork this directory into their own codebase
7
+ * and customize per their substrate's specifics.
8
+ *
9
+ * What it does:
10
+ * - Maps `agent_id` → URL via JSON config (per audit v0.9.7 Q6).
11
+ * - POSTs DeliveryPayload (with `agent_id` at top-level for receiver routing)
12
+ * as JSON to the configured URL.
13
+ * - Synthesizes a DeliveryReceipt from the HTTP response (substrate-specific
14
+ * response shapes are translated here, not assumed canonical).
15
+ * - health_check() pings configured agents (or returns true if none configured).
16
+ * - wake() throws if no wake URL configured for the agent.
17
+ * - agent_status?() not implemented (optional per contract).
18
+ * - request_response() throws NotImplementedError until v0.10 exchange() ships.
19
+ *
20
+ * Deployment models the wire format supports without code changes:
21
+ * - Model A: one URL per agent_id. Routing decided by URL (different hosts /
22
+ * ports / paths). Adopter runs multiple receivers or one with URL-distinguished
23
+ * routing.
24
+ * - Model B: single URL, router receiver. Agent_id lives in the POST body;
25
+ * receiver inspects + dispatches. Cleaner when adopter already has a
26
+ * routing layer.
27
+ * - Model C: variable-driven channel selection IN THE SKILL —
28
+ * `notify(agent="agent-${CHANNEL}", ...)` — works on top of A or B.
29
+ *
30
+ * Auth: no auth by default. Bearer-token and HMAC-SHA256 paths are stubbed +
31
+ * commented; adopters enable per their threat model.
32
+ *
33
+ * What it deliberately doesn't do (fork to add):
34
+ * - Retry on 5xx — fail-fast; adopters' retry semantics differ per substrate.
35
+ * - Multi-region failover URLs per agent.
36
+ * - OAuth flows, mTLS, SAML — adopter-specific auth providers.
37
+ * - Streaming / chunked deliveries (HTTP/2 push, websockets).
38
+ * - Async-callback reply pattern for request_response() (v0.10 design choice).
39
+ */
40
+
41
+ // Adopters who fork this example: replace these relative imports with
42
+ // `import type { ... } from "skillscript-runtime/connectors";`
43
+ import type {
44
+ AgentConnector,
45
+ AgentDescriptor,
46
+ DeliveryPayload,
47
+ DeliveryReceipt,
48
+ RequestResponseOpts,
49
+ Response as AgentResponse,
50
+ WakeOpts,
51
+ WakeReceipt,
52
+ } from "../../../src/connectors/agent.js";
53
+ import type { StaticCapabilities } from "../../../src/connectors/types.js";
54
+
55
+ /** Per-agent config — `url` required; `wake_url` + `status_url` optional. */
56
+ export interface HttpWebhookAgentConfig {
57
+ url: string;
58
+ wake_url?: string;
59
+ status_url?: string;
60
+ }
61
+
62
+ /** Connector construction options — all derivable from `.env`. */
63
+ export interface HttpWebhookAgentConnectorOptions {
64
+ /** Map of agent_id → per-agent config. Required; empty map means no agents wired. */
65
+ agents: Record<string, HttpWebhookAgentConfig>;
66
+ /** Per-request timeout in ms (total request duration via AbortSignal.timeout). Default 5000. */
67
+ timeout_ms?: number;
68
+ /**
69
+ * `Authorization` header value (e.g., `"Bearer abc123"`). When set, every
70
+ * outbound POST includes `Authorization: <value>`. Can be combined with
71
+ * `hmac_secret` for auth + body-integrity; most adopters need only one.
72
+ */
73
+ authorization?: string;
74
+ /**
75
+ * HMAC-SHA256 secret. When set, every outbound POST is signed:
76
+ * `X-Signature: sha256=<hex(HMAC_SHA256(secret, raw_body))>`.
77
+ * Receiver MUST validate signature against the RAW HTTP body BEFORE
78
+ * parsing JSON — common foot-gun if validation happens post-parse
79
+ * (key ordering / whitespace drift breaks the hash).
80
+ */
81
+ hmac_secret?: string;
82
+ }
83
+
84
+ /** Thrown when an HTTP request fails (network error, timeout, 4xx/5xx, or local validation). */
85
+ export class DeliveryFailedError extends Error {
86
+ constructor(
87
+ public readonly agent_id: string,
88
+ public readonly cause_kind: "network" | "timeout" | "http_status" | "malformed_response" | "client_validation",
89
+ public readonly http_status: number | null,
90
+ message: string,
91
+ ) {
92
+ super(message);
93
+ this.name = "DeliveryFailedError";
94
+ }
95
+ }
96
+
97
+ export class HttpWebhookAgentConnector implements AgentConnector {
98
+ static staticCapabilities(): StaticCapabilities {
99
+ return {
100
+ connector_type: "agent_connector",
101
+ implementation: "HttpWebhookAgentConnector",
102
+ contract_version: "1.0.0",
103
+ features: { deliver: true, wake: true, list_agents: true, health_check: true },
104
+ };
105
+ }
106
+
107
+ private readonly agents: Record<string, HttpWebhookAgentConfig>;
108
+ private readonly timeout_ms: number;
109
+ private readonly authorization?: string;
110
+ private readonly hmac_secret?: string;
111
+
112
+ constructor(opts: HttpWebhookAgentConnectorOptions) {
113
+ this.agents = opts.agents;
114
+ this.timeout_ms = opts.timeout_ms ?? 5000;
115
+ this.authorization = opts.authorization;
116
+ this.hmac_secret = opts.hmac_secret;
117
+ }
118
+
119
+ /**
120
+ * Convenience: build from process.env (looks up the bundled env-var names).
121
+ *
122
+ * Validates env-var shape strictly — adopter footguns (NaN timeout, malformed
123
+ * agents JSON, missing required fields) surface at construction time, not at
124
+ * first dispatch failure. The validation pattern below is intentionally
125
+ * educational: adopter forks should copy it.
126
+ */
127
+ static fromEnv(env: NodeJS.ProcessEnv = process.env): HttpWebhookAgentConnector {
128
+ const raw = env["HTTP_WEBHOOK_AGENTS"];
129
+ if (raw === undefined || raw === "") {
130
+ throw new Error("HTTP_WEBHOOK_AGENTS env var is required (JSON map of agent_id → config).");
131
+ }
132
+ let parsed: unknown;
133
+ try {
134
+ parsed = JSON.parse(raw);
135
+ } catch (err) {
136
+ throw new Error(`HTTP_WEBHOOK_AGENTS must be valid JSON: ${(err as Error).message}`);
137
+ }
138
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
139
+ throw new Error("HTTP_WEBHOOK_AGENTS must be a JSON object (map of agent_id → config).");
140
+ }
141
+ for (const [agent_id, cfg] of Object.entries(parsed)) {
142
+ if (cfg === null || typeof cfg !== "object" || Array.isArray(cfg)) {
143
+ throw new Error(`HTTP_WEBHOOK_AGENTS["${agent_id}"]: must be a config object (got ${typeof cfg}).`);
144
+ }
145
+ if (typeof (cfg as HttpWebhookAgentConfig).url !== "string" || (cfg as HttpWebhookAgentConfig).url === "") {
146
+ throw new Error(`HTTP_WEBHOOK_AGENTS["${agent_id}"]: missing required "url" string field.`);
147
+ }
148
+ }
149
+ const agents = parsed as Record<string, HttpWebhookAgentConfig>;
150
+
151
+ const rawTimeout = env["HTTP_WEBHOOK_TIMEOUT_MS"];
152
+ const timeout_ms = rawTimeout !== undefined ? Number.parseInt(rawTimeout, 10) : 5000;
153
+ if (Number.isNaN(timeout_ms) || timeout_ms <= 0) {
154
+ throw new Error(`HTTP_WEBHOOK_TIMEOUT_MS must be a positive integer (got: "${rawTimeout}").`);
155
+ }
156
+
157
+ return new HttpWebhookAgentConnector({
158
+ agents,
159
+ timeout_ms,
160
+ ...(env["HTTP_WEBHOOK_AUTH"] !== undefined && env["HTTP_WEBHOOK_AUTH"] !== "" ? { authorization: env["HTTP_WEBHOOK_AUTH"] } : {}),
161
+ ...(env["HTTP_WEBHOOK_HMAC_SECRET"] !== undefined && env["HTTP_WEBHOOK_HMAC_SECRET"] !== "" ? { hmac_secret: env["HTTP_WEBHOOK_HMAC_SECRET"] } : {}),
162
+ });
163
+ }
164
+
165
+ async list_agents(): Promise<AgentDescriptor[]> {
166
+ // Unconditional return per audit Q5.1 — no network ping on the list path
167
+ // (runtime dispatch calls this to validate connector ownership; ping-first
168
+ // would double round-trips on every dispatch).
169
+ return Object.keys(this.agents).map((agent_id) => ({ agent_id }));
170
+ }
171
+
172
+ async deliver(agent_id: string, payload: DeliveryPayload): Promise<DeliveryReceipt> {
173
+ const cfg = this.agents[agent_id];
174
+ if (cfg === undefined) {
175
+ throw new DeliveryFailedError(
176
+ agent_id, "client_validation", null,
177
+ `Agent '${agent_id}' is not configured. Wired agents: ${Object.keys(this.agents).join(", ") || "(none)"}.`,
178
+ );
179
+ }
180
+
181
+ // Wire body: include agent_id at top-level alongside the canonical
182
+ // DeliveryPayload shape so Model B (single-URL router receivers) can
183
+ // dispatch without inspecting URL.
184
+ const body = JSON.stringify({ agent_id, ...payload });
185
+
186
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
187
+ if (this.authorization !== undefined) {
188
+ headers["Authorization"] = this.authorization;
189
+ }
190
+ if (this.hmac_secret !== undefined) {
191
+ const { createHmac } = await import("node:crypto");
192
+ const sig = createHmac("sha256", this.hmac_secret).update(body, "utf8").digest("hex");
193
+ headers["X-Signature"] = `sha256=${sig}`;
194
+ }
195
+
196
+ let response: globalThis.Response;
197
+ try {
198
+ response = await fetch(cfg.url, {
199
+ method: "POST",
200
+ headers,
201
+ body,
202
+ signal: AbortSignal.timeout(this.timeout_ms),
203
+ });
204
+ } catch (err) {
205
+ const e = err as Error;
206
+ const kind = e.name === "TimeoutError" || e.name === "AbortError" ? "timeout" : "network";
207
+ throw new DeliveryFailedError(agent_id, kind, null, `${kind} error: ${e.message}`);
208
+ }
209
+
210
+ if (response.status >= 400) {
211
+ throw new DeliveryFailedError(
212
+ agent_id, "http_status", response.status,
213
+ `HTTP ${response.status} from ${cfg.url}`,
214
+ );
215
+ }
216
+
217
+ // Receiver returns the canonical DeliveryReceipt shape when it can.
218
+ // Real-world webhooks (NanoClaw, Discord, Slack) return their own shapes;
219
+ // we tolerantly accept either by parsing what we can and synthesizing
220
+ // a canonical receipt.
221
+ let receiverBody: unknown = {};
222
+ try {
223
+ const text = await response.text();
224
+ if (text.length > 0) receiverBody = JSON.parse(text);
225
+ } catch {
226
+ // Malformed JSON from receiver — synthesize a minimal receipt rather
227
+ // than throw. The HTTP layer reported success; agent-side observability
228
+ // sees delivered_at populated.
229
+ }
230
+ return synthesizeReceipt(receiverBody);
231
+ }
232
+
233
+ async wake(agent_id: string, _opts?: WakeOpts): Promise<WakeReceipt> {
234
+ const cfg = this.agents[agent_id];
235
+ if (cfg === undefined) {
236
+ throw new Error(`wake('${agent_id}'): agent not configured`);
237
+ }
238
+ if (cfg.wake_url === undefined) {
239
+ throw new Error(
240
+ `wake('${agent_id}'): no wake_url configured for this agent. ` +
241
+ `Add "wake_url" to the agent's config entry in HTTP_WEBHOOK_AGENTS if your substrate supports wake.`,
242
+ );
243
+ }
244
+ // Adopter-specific wake protocol; bundled example just POSTs to the URL.
245
+ const response = await fetch(cfg.wake_url, {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ body: JSON.stringify({ agent_id }),
249
+ signal: AbortSignal.timeout(this.timeout_ms),
250
+ });
251
+ if (response.status >= 400) {
252
+ throw new Error(`wake('${agent_id}'): HTTP ${response.status}`);
253
+ }
254
+ return { woken_at: Date.now() };
255
+ }
256
+
257
+ async health_check(): Promise<boolean> {
258
+ // Default: ping each configured agent's status_url (if any). If no
259
+ // status_urls configured, return true (we have no probe surface; trust
260
+ // the URL config until first deliver() proves otherwise).
261
+ const probes = Object.values(this.agents).filter((c) => c.status_url !== undefined);
262
+ if (probes.length === 0) return true;
263
+ try {
264
+ const results = await Promise.all(probes.map((c) =>
265
+ fetch(c.status_url!, { method: "GET", signal: AbortSignal.timeout(this.timeout_ms) }),
266
+ ));
267
+ return results.every((r) => r.status >= 200 && r.status < 300);
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ async request_response(
274
+ agent_id: string,
275
+ _payload: DeliveryPayload,
276
+ _opts: RequestResponseOpts,
277
+ ): Promise<AgentResponse> {
278
+ // v0.10 design choice pending: (a) synchronous hold-connection — sender
279
+ // keeps HTTP connection open, receiver returns reply in body; (b) async
280
+ // callback — sender provides callback URL in request, receiver POSTs
281
+ // reply back. The HTTP impl picks one when v0.10 exchange() ships.
282
+ throw new Error(
283
+ `[HttpWebhookAgentConnector] request_response('${agent_id}') — not implemented. ` +
284
+ `Synchronous request-response shipping with v0.10 exchange() op.`,
285
+ );
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Translate the receiver's HTTP response body into a canonical DeliveryReceipt.
291
+ * Receivers may return our exact shape, or substrate-specific shapes (Discord
292
+ * message JSON, NanoClaw `{status, id}`, Slack ts+channel, etc.). Tolerate
293
+ * variation; synthesize the canonical fields.
294
+ *
295
+ * Adopters with strict substrate shape can replace this function (or write
296
+ * a wrapper) to enforce. Bundled example is permissive by design.
297
+ */
298
+ function synthesizeReceipt(body: unknown): DeliveryReceipt {
299
+ const now = Date.now();
300
+ if (body === null || typeof body !== "object") {
301
+ return { delivered_at: now };
302
+ }
303
+ const obj = body as Record<string, unknown>;
304
+ const receipt: DeliveryReceipt = {
305
+ delivered_at: typeof obj["delivered_at"] === "number" ? (obj["delivered_at"] as number) : now,
306
+ };
307
+ // Common substrate response fields that map to delivery_id:
308
+ // - NanoClaw: { status, id }
309
+ // - Discord: { id, ... }
310
+ // - Slack: { ts, channel, ... }
311
+ const idCandidate = obj["delivery_id"] ?? obj["id"] ?? obj["ts"];
312
+ if (typeof idCandidate === "string") {
313
+ receipt.delivery_id = idCandidate;
314
+ }
315
+ // Adopter signals "accepted but not pushed":
316
+ if (obj["delivery_skipped"] === true) {
317
+ receipt.delivery_skipped = true;
318
+ }
319
+ return receipt;
320
+ }
@@ -0,0 +1,266 @@
1
+ # HttpWebhookAgentConnector — example
2
+
3
+ Worked example of `AgentConnector` against an HTTP-webhook substrate. Copy this directory into your codebase, customize per your substrate, register with skillscript-runtime's `Registry`. This README is written for the agent implementing your adopter's connector — including the human reviewing the PR.
4
+
5
+ **What this demonstrates**: the locked v1.0 contract surface (Q1-Q12 from the v0.9.6 audit) wired through real HTTP traffic, plus the three deployment models the wire format supports.
6
+
7
+ ---
8
+
9
+ ## Quick start
10
+
11
+ ```typescript
12
+ import { Registry } from "skillscript-runtime";
13
+ import { HttpWebhookAgentConnector } from "./HttpWebhookAgentConnector.js";
14
+
15
+ const registry = new Registry();
16
+ // Async — bootstrap-throws on health_check() returning false (audit Q6).
17
+ await registry.registerAgentConnector("primary", HttpWebhookAgentConnector.fromEnv());
18
+ ```
19
+
20
+ Author writes skills like:
21
+
22
+ ```
23
+ # Skill: morning-status
24
+ # Status: Approved
25
+ # Output: agent: agent-slack
26
+ m:
27
+ emit(text="overnight sweep clean")
28
+ default: m
29
+ ```
30
+
31
+ Runs → POSTs to the URL configured for `agent-slack` → receiver lands the message in its substrate (Slack, Discord, NanoClaw, whatever).
32
+
33
+ ---
34
+
35
+ ## Configuration
36
+
37
+ Three env vars; see `.env.example`.
38
+
39
+ `HTTP_WEBHOOK_AGENTS` — required. Nested JSON map; keys are `agent_id` strings; values are per-agent config:
40
+
41
+ ```json
42
+ {
43
+ "agent-slack": { "url": "http://localhost:3200/webhook/slack", "wake_url": "http://localhost:3200/wake/slack" },
44
+ "agent-whatsapp": { "url": "http://localhost:3200/webhook/whatsapp" }
45
+ }
46
+ ```
47
+
48
+ - `url` (required) — POST destination for `deliver()` calls
49
+ - `wake_url` (optional) — POST destination for `wake()` calls; throws if missing + skill calls `wake()`
50
+ - `status_url` (optional) — GET probe for `agent_status?()` + `health_check()`; skipped if missing
51
+
52
+ `HTTP_WEBHOOK_TIMEOUT_MS` — total request duration (connect + send + receive + parse). Default `5000`.
53
+
54
+ `HTTP_WEBHOOK_AUTH` — bearer-token value. When set, every POST includes `Authorization: <value>`. Use for substrate endpoints behind a shared secret.
55
+
56
+ `HTTP_WEBHOOK_HMAC_SECRET` — HMAC-SHA256 signing secret. When set, every POST includes `X-Signature: sha256=<hex>`. See [Auth section](#auth) for the body-signing semantics.
57
+
58
+ ---
59
+
60
+ ## Wire format
61
+
62
+ ### Outbound (skillscript → your receiver)
63
+
64
+ `POST <configured-URL>` with `Content-Type: application/json`:
65
+
66
+ ```json
67
+ {
68
+ "agent_id": "agent-slack",
69
+ "kind": "augment",
70
+ "content": "the message text",
71
+ "meta": {
72
+ "dispatch_id": "uuid-v4",
73
+ "sent_at": 1700000000000,
74
+ "origin": {
75
+ "skill_name": "morning-status",
76
+ "trigger_kind": "cron"
77
+ },
78
+ "event_type": "status-update"
79
+ }
80
+ }
81
+ ```
82
+
83
+ Field semantics:
84
+
85
+ - **`agent_id`** (top-level, NOT in canonical DeliveryPayload) — included here so Model B receivers (single URL, body-routed) can dispatch without inspecting URL.
86
+ - **`kind`** — `"augment"` (context-to-absorb) or `"template"` (playbook-to-execute).
87
+ - **`content`** / **`prompt`** — message body. `content` for augment kind; `prompt` for template kind.
88
+ - **`meta`** — runtime-filled envelope (skillscript v1.0 contract Q8).
89
+ - **`dispatch_id`** — UUID per `notify()` invocation. Receivers use for substrate-retry idempotency. Multi-connector broadcast shares dispatch_id; sequential `notify()` calls produce distinct ids.
90
+ - **`sent_at`** — sender's emit-clock (unix ms). NOT the substrate's delivered_at.
91
+ - **`origin.skill_name`** — emitter skill.
92
+ - **`origin.entry_skill_name`** (optional) — root entry-point skill when distinct from `skill_name` (set during procedural composition).
93
+ - **`origin.trigger_kind`** — `cron` / `session` / `webhook` / `agent` / `cli` / `dashboard` / `inline`.
94
+ - **`origin.caller_agent_id`** (optional) — root-trigger agent IF identifiable, else absent.
95
+ - **`event_type`** (optional) — adopter-defined routing vocabulary.
96
+ - **`correlation_id`** (optional) — reply-correlation for future v0.10 `exchange()` op.
97
+
98
+ **Optional fields are ABSENT when not set, never present-as-null.** TypeScript `?` semantics: `JSON.stringify` elides `undefined` keys. Receiver code: `if (meta.event_type)` works; `if (meta.event_type !== null)` is wrong.
99
+
100
+ ### Inbound (your receiver → skillscript)
101
+
102
+ Return JSON body matching canonical `DeliveryReceipt`:
103
+
104
+ ```json
105
+ {
106
+ "delivered_at": 1700000000005,
107
+ "delivery_id": "your-substrate-id-optional",
108
+ "delivery_skipped": false
109
+ }
110
+ ```
111
+
112
+ **The connector is tolerant about receiver response shapes** — if your substrate returns something different (NanoClaw `{status, id}`, Discord message JSON, Slack `{ts, channel}`), the connector synthesizes a canonical receipt by reading common fields:
113
+
114
+ - `delivered_at` ← receiver's value, else `Date.now()`
115
+ - `delivery_id` ← `delivery_id` / `id` / `ts` (first match)
116
+ - `delivery_skipped` ← receiver's value if `true`
117
+
118
+ Adopters with strict substrate shape can replace the `synthesizeReceipt()` helper. Bundled example is permissive.
119
+
120
+ ### HTTP status codes
121
+
122
+ - **2xx** → parse JSON receipt; return through
123
+ - **4xx** → throw `DeliveryFailedError(kind: "http_status")` — permanent failure
124
+ - **5xx** → throw `DeliveryFailedError(kind: "http_status")` — transient (adopter with retry policy forks around this)
125
+ - **Network error / timeout** → throw `DeliveryFailedError(kind: "network" | "timeout")`
126
+
127
+ ---
128
+
129
+ ## Three deployment models
130
+
131
+ The wire format supports all three without connector code changes:
132
+
133
+ ### Model A — One URL per agent_id
134
+
135
+ ```json
136
+ {
137
+ "agent-slack": { "url": "http://nanoclaw-a:3200/webhook" },
138
+ "agent-whatsapp": { "url": "http://nanoclaw-b:3201/webhook" }
139
+ }
140
+ ```
141
+
142
+ Each URL is fully self-contained — receiver knows where to route by virtue of which host/port/path it was hit on. Adopter runs multiple receivers OR one receiver with URL-distinguished routing (paths/query params).
143
+
144
+ ### Model B — Single URL, router receiver, agent_id in body
145
+
146
+ ```json
147
+ {
148
+ "agent-slack": { "url": "http://receiver:3200/webhook" },
149
+ "agent-whatsapp": { "url": "http://receiver:3200/webhook" }
150
+ }
151
+ ```
152
+
153
+ Both POSTs go to the SAME URL. Receiver inspects `body.agent_id` and dispatches accordingly. Cleaner when adopter already has a routing layer (e.g., one Slack workspace with channel-as-agent_id).
154
+
155
+ ### Model C — Variable-driven channel selection in the skill
156
+
157
+ ```
158
+ # Skill: contextual-alert
159
+ # Status: Approved
160
+ # Vars: CHANNEL="slack"
161
+
162
+ m:
163
+ notify(agent="agent-${CHANNEL}", message="alert")
164
+ default: m
165
+ ```
166
+
167
+ Works on top of Model A or B. The skill picks channel at RUNTIME via `${VAR}` substitution. Caller can override per-invocation: `execute_skill(inputs={CHANNEL: "whatsapp"})`.
168
+
169
+ ---
170
+
171
+ ## Auth
172
+
173
+ ### No auth (default)
174
+
175
+ Skip both `HTTP_WEBHOOK_AUTH` and `HTTP_WEBHOOK_HMAC_SECRET`. Suitable for substrates behind a network boundary (host-local, container-network, VPN-only).
176
+
177
+ ### Bearer token
178
+
179
+ ```
180
+ HTTP_WEBHOOK_AUTH=Bearer abc123
181
+ ```
182
+
183
+ Connector adds `Authorization: Bearer abc123` to every POST. Receiver validates the header. Suitable for trusted internal endpoints; simple shared-secret pattern.
184
+
185
+ **Bearer + HMAC are combinable.** When both `HTTP_WEBHOOK_AUTH` and `HTTP_WEBHOOK_HMAC_SECRET` are set, every POST gets both an `Authorization` header AND an `X-Signature` header. Most adopters need only one; combine when you want auth-identity AND body-integrity (e.g., bearer identifies the caller, HMAC proves the body wasn't tampered with downstream of caller).
186
+
187
+ ### HMAC-SHA256 body signing
188
+
189
+ ```
190
+ HTTP_WEBHOOK_HMAC_SECRET=<base64-or-hex-secret>
191
+ ```
192
+
193
+ Connector signs the RAW HTTP body with HMAC-SHA256 and adds `X-Signature: sha256=<hex>` header. Use when body-integrity matters (e.g., when HTTPS terminates somewhere unexpected, or when receiver wants tamper-detection).
194
+
195
+ **Receiver MUST validate signature against the RAW HTTP body BEFORE parsing JSON.** Common foot-gun: receiver decodes JSON first, then re-encodes for hashing → hash mismatch from key ordering / whitespace drift. The bundled receiver examples demonstrate the correct pattern (Express uses `express.raw()`; Flask uses `request.get_data()`).
196
+
197
+ ### Roll your own (OAuth, mTLS, etc.)
198
+
199
+ Fork `HttpWebhookAgentConnector.ts` + customize the request-construction path. The contract is `AgentConnector.deliver()` — anything that satisfies it works.
200
+
201
+ ---
202
+
203
+ ## Receiver examples
204
+
205
+ Two reference snippets in `receiver-example/`:
206
+
207
+ - **`express.js`** — Node + Express. ~50 LOC including HMAC validation.
208
+ - **`flask.py`** — Python + Flask. ~50 LOC, same shape.
209
+
210
+ Both demonstrate:
211
+ - Raw-body parsing for HMAC validation
212
+ - Skillscript envelope → substrate routing translation
213
+ - Canonical DeliveryReceipt response shape
214
+ - Clock-skew note for staleness checks
215
+
216
+ Copy + customize per your substrate. These are reference patterns, not shipped code — there are no tests because this is your code path.
217
+
218
+ ---
219
+
220
+ ## What's deliberately out of scope (fork to add)
221
+
222
+ - **Retries** — example is fail-fast. Retry semantics are substrate-specific (webhook 5xx ≠ rate-limit 429 ≠ substrate-down). Adopters wanting retries customize `deliver()` directly.
223
+ - **Multi-region failover URLs per agent** — adopter-specific.
224
+ - **OAuth flows / mTLS / SAML** — adopter-specific auth providers.
225
+ - **Streaming deliveries (HTTP/2 push, websockets)** — different substrate class.
226
+ - **Async-callback reply pattern for `request_response()`** — v0.10 design choice (sync hold-connection vs async callback). Today the method throws `NotImplementedError` until v0.10 ships `exchange()`.
227
+
228
+ ---
229
+
230
+ ## Forking discipline
231
+
232
+ If you fork this example into your codebase:
233
+ - **Don't modify the canonical `DeliveryPayload` / `DeliveryReceipt` shapes** — those are skillscript-runtime contracts. Your customizations go in HOW you serialize / parse, not WHAT.
234
+ - **Keep `agent_id` at top-level in the POST body IF you want Model B receivers downstream of your fork** to route without URL inspection. Model-A-only deployments (URL-distinguished routing) can drop it; the canonical contract doesn't require it.
235
+ - **Preserve the `meta` envelope** — adopters downstream rely on `dispatch_id` / `sent_at` / `origin` / `event_type` / `correlation_id`.
236
+ - **If you change the auth model, document it** in your fork's README. Adopter-agents reading your code need to know what's at stake.
237
+
238
+ ---
239
+
240
+ ## Tests
241
+
242
+ `tests/HttpWebhookAgentConnector.test.ts` exercises:
243
+
244
+ - `deliver()` posts the correct body shape (agent_id + kind + content + meta)
245
+ - Both `kind: "augment"` and `kind: "template"` round-trip
246
+ - Receiver returns canonical receipt → parsed cleanly
247
+ - Receiver returns substrate-shaped response → synthesizeReceipt translates
248
+ - Receiver returns `delivery_skipped: true` → honored on the receipt
249
+ - 4xx / 5xx → throws `DeliveryFailedError`
250
+ - Network timeout → throws `DeliveryFailedError`
251
+ - `list_agents()` returns all configured agent_ids
252
+ - Multi-agent_id routes to distinct URLs
253
+ - `health_check()` returns true with no status_urls configured
254
+ - `request_response()` throws NotImplementedError (Q1 v0.10 deferred)
255
+ - HMAC signing produces the correct `X-Signature` header value
256
+ - Bearer auth sets the correct `Authorization` header
257
+
258
+ Run via `vitest run examples/connectors/HttpWebhookAgentConnector/tests/`.
259
+
260
+ ---
261
+
262
+ ## Cross-references
263
+
264
+ - [Connector Contract Reference](../../../docs/connector-contract-reference.md) — canonical AgentConnector contract
265
+ - [Adopter Playbook](../../../docs/adopter-playbook.md) — broader adopter context
266
+ - [Language Reference](../../../docs/language-reference.md) — `notify()` op + `# Output: agent:` lifecycle hook syntax
@@ -0,0 +1,72 @@
1
+ // Reference receiver for HttpWebhookAgentConnector — Node + Express.
2
+ // Copy + customize per your substrate. ~30 LOC; readable in 60 seconds.
3
+ //
4
+ // What this demonstrates:
5
+ // - Accepts POST with JSON body matching the canonical DeliveryPayload shape
6
+ // (kind + content + meta + agent_id at top-level for routing).
7
+ // - Translates skillscript envelope → your substrate's routing layer.
8
+ // - Returns canonical DeliveryReceipt shape (delivered_at + optional delivery_id).
9
+ //
10
+ // HMAC validation note: if you use HTTP_WEBHOOK_HMAC_SECRET on the sender,
11
+ // validate the X-Signature header against the RAW HTTP body BEFORE parsing
12
+ // JSON. The pattern below uses express.raw() to expose req.body as a Buffer.
13
+
14
+ const express = require("express");
15
+ const crypto = require("crypto");
16
+ const { createHmac } = crypto;
17
+
18
+ const PORT = parseInt(process.env.PORT ?? "3200", 10);
19
+ const HMAC_SECRET = process.env.HMAC_SECRET; // optional
20
+
21
+ const app = express();
22
+ app.use(express.raw({ type: "application/json", limit: "1mb" }));
23
+
24
+ app.post("/webhook/:channel?", (req, res) => {
25
+ // 1) Validate HMAC signature against raw body (BEFORE parsing).
26
+ if (HMAC_SECRET !== undefined) {
27
+ const expected = `sha256=${createHmac("sha256", HMAC_SECRET).update(req.body).digest("hex")}`;
28
+ if (req.headers["x-signature"] !== expected) {
29
+ return res.status(401).json({ error: "invalid signature" });
30
+ }
31
+ }
32
+
33
+ // 2) Parse JSON body. Now safe — signature validated against raw bytes.
34
+ let payload;
35
+ try {
36
+ payload = JSON.parse(req.body.toString("utf8"));
37
+ } catch {
38
+ return res.status(400).json({ error: "malformed JSON" });
39
+ }
40
+
41
+ // 3) Translate skillscript envelope → your routing layer.
42
+ // payload.agent_id is the top-level routing key (Model B receivers can
43
+ // dispatch from this alone). req.params.channel is URL-based routing
44
+ // (Model A). Use whichever fits your substrate.
45
+ const text = `[${payload.meta?.origin?.skill_name ?? "skill"}] ${payload.content ?? payload.prompt ?? ""}`;
46
+ const senderName = `skillscript:${payload.meta?.origin?.skill_name ?? "unknown"}`;
47
+
48
+ // your-substrate-here:
49
+ console.log(`[${req.params.channel ?? payload.agent_id}] ${senderName}: ${text}`);
50
+ // e.g., NanoClaw-style:
51
+ // routeInbound({channelType, platformId, threadId: null,
52
+ // message: { id: payload.meta?.dispatch_id, body: text, sender: senderName }});
53
+
54
+ // 4) Return canonical DeliveryReceipt. delivery_id echoes dispatch_id so
55
+ // sender can correlate. Set delivery_skipped: true if substrate accepts
56
+ // but won't actually deliver (agent offline, rate-limit, etc.).
57
+ res.json({
58
+ delivered_at: Date.now(),
59
+ delivery_id: payload.meta?.dispatch_id,
60
+ });
61
+ });
62
+
63
+ app.get("/health", (_req, res) => res.json({ status: "ok" }));
64
+
65
+ app.listen(PORT, "0.0.0.0", () => {
66
+ console.log(`HttpWebhookAgentConnector receiver-example listening on :${PORT}`);
67
+ });
68
+
69
+ // Clock-skew note: payload.meta.sent_at is the SENDER's emit-clock; your
70
+ // own clock when this request arrives may drift. If you compute staleness
71
+ // as `now - payload.meta.sent_at`, use `Math.max(0, delta)` to avoid
72
+ // negative values when receiver clock runs ahead.