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.
- package/dist/connectors/types.d.ts +79 -0
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/connectors/types.js.map +1 -1
- package/dist/dashboard/spa/app.js +10 -3
- package/dist/mcp-server.d.ts +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +9 -4
- package/dist/mcp-server.js.map +1 -1
- package/dist/skill-catalog.d.ts +38 -0
- package/dist/skill-catalog.d.ts.map +1 -0
- package/dist/skill-catalog.js +159 -0
- package/dist/skill-catalog.js.map +1 -0
- package/examples/connectors/HttpWebhookAgentConnector/.env.example +34 -0
- package/examples/connectors/HttpWebhookAgentConnector/HttpWebhookAgentConnector.ts +320 -0
- package/examples/connectors/HttpWebhookAgentConnector/README.md +266 -0
- package/examples/connectors/HttpWebhookAgentConnector/receiver-example/express.js +72 -0
- package/examples/connectors/HttpWebhookAgentConnector/receiver-example/flask.py +77 -0
- package/examples/connectors/HttpWebhookAgentConnector/tests/HttpWebhookAgentConnector.test.ts +378 -0
- package/examples/hello.skill.provenance.json +1 -1
- package/package.json +1 -1
|
@@ -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.
|