@specstream/openclaw-plugin 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +93 -2
- package/assets/icon.png +0 -0
- package/dist/client.js +234 -0
- package/dist/config.js +32 -0
- package/dist/index.js +81 -0
- package/openclaw.plugin.json +80 -0
- package/package.json +67 -4
- package/skills/specstream-approve-work/SKILL.md +17 -0
- package/skills/specstream-compile/SKILL.md +15 -0
- package/skills/specstream-continue-work/SKILL.md +19 -0
- package/skills/specstream-review-work/SKILL.md +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpecStream SAS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @specstream/openclaw-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ask your OpenClaw agent for a list and get the whole list back. "Dental clinics in Lisbon with online booking, with their website and phone." Every row comes from the live web with the page it was read from, so you can open it and look. You pay per match, and a search that finds nothing costs nothing.
|
|
4
|
+
|
|
5
|
+
[SpecStream](https://specstream.ai) is the live web data engine for teams and agents. This plugin gives OpenClaw two tools for it and, if you want the full conversation with SpecStream, four skills.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
From ClawHub:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
openclaw plugins install clawhub:@specstream/openclaw-plugin
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or from npm:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
openclaw plugins install @specstream/openclaw-plugin
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Needs OpenClaw 2026.10.3 or newer.
|
|
22
|
+
|
|
23
|
+
## Set it up
|
|
24
|
+
|
|
25
|
+
Create an API key under **Agents & API** in your [SpecStream workspace](https://specstream.ai/workspace). New accounts start with $10 of work free for 30 days, no card.
|
|
26
|
+
|
|
27
|
+
The plugin reads the key from `SPECSTREAM_API_KEY`. To keep it in your OpenClaw config instead, store a SecretRef that points at the variable:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
openclaw config set plugins.entries.specstream.config.apiKey \
|
|
31
|
+
'{"source":"env","provider":"default","id":"SPECSTREAM_API_KEY"}' --strict-json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then set the most one list may cost. This is the number that lets you hand the agent a tool that spends money:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
openclaw config set plugins.entries.specstream.config.maxPriceUsd 5
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
| Option | Default | What it does |
|
|
41
|
+
| --- | --- | --- |
|
|
42
|
+
| `apiKey` | `SPECSTREAM_API_KEY` | Your SpecStream API key, or a SecretRef |
|
|
43
|
+
| `maxPriceUsd` | `5` | The price ceiling for one list, in US dollars |
|
|
44
|
+
| `waitSeconds` | `300` | How long a find waits for a running list before it returns the rows so far |
|
|
45
|
+
|
|
46
|
+
Give the key only what the task needs: its scope, projects, an expiry and a spending limit.
|
|
47
|
+
|
|
48
|
+
## The tools
|
|
49
|
+
|
|
50
|
+
### `specstream_find_list`
|
|
51
|
+
|
|
52
|
+
| Input | |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `request` | The list in plain words: what to find, where, what every row must meet, and the facts to bring back |
|
|
55
|
+
| `max_results` | How many rows, 1 to 1000. Default 25 |
|
|
56
|
+
| `max_price_usd` | Optional. A lower ceiling for this call |
|
|
57
|
+
|
|
58
|
+
The tool reads the request, gets the price, and starts the run only if the quoted maximum is within your ceiling. The agent can ask for a lower ceiling on a call and can never raise yours. If the price is above the ceiling, nothing runs and the agent is told so. Results cost 10 cents, 30 cents or $1 each depending on the list, plus 10 cents for a person's email and 25 cents for a phone number, and nothing when nothing matches.
|
|
59
|
+
|
|
60
|
+
It waits for the list, then returns JSON: `state`, `list_id`, `list_link` and `rows`. Each row has its columns plus `source_url` and `checked_at`. A retry of the same request with the same limits within the hour reaches the run it already started, so a flaky turn does not buy the list twice.
|
|
61
|
+
|
|
62
|
+
### `specstream_get_list_rows`
|
|
63
|
+
|
|
64
|
+
Reads every row of a list you already own, by `list_id`. Free. Use it when a big list was still running at the end of the wait.
|
|
65
|
+
|
|
66
|
+
## Skills and the SpecStream server (optional)
|
|
67
|
+
|
|
68
|
+
The plugin ships four skills: `specstream-compile` (read the request back), `specstream-review-work` (get the price), `specstream-approve-work` (approve it) and `specstream-continue-work` (read the rows, send them to Sheets, Notion, HubSpot and more, keep them current). They use the hosted SpecStream server and stay hidden until you add it:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
openclaw mcp set specstream '{"url":"https://api.specstream.ai/mcp","transport":"streamable-http","auth":"oauth"}'
|
|
72
|
+
openclaw mcp login specstream
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The tools above work without it.
|
|
76
|
+
|
|
77
|
+
## Example
|
|
78
|
+
|
|
79
|
+
> Find food banks in Texas with their annual revenue. Keep it under $3.
|
|
80
|
+
|
|
81
|
+
The agent calls `specstream_find_list` with `max_price_usd: 3`, and you get a table of food banks, each row with the page it came from, plus a link to open the list in your workspace.
|
|
82
|
+
|
|
83
|
+
## Develop
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm ci
|
|
87
|
+
npm run check # tsc, source and tests
|
|
88
|
+
npm test # vitest, fetch mocked
|
|
89
|
+
npm run plugin:validate # OpenClaw's own manifest validator
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/assets/icon.png
ADDED
|
Binary file
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** The SpecStream REST calls the tools share: read a request, price it, start it within a maximum, read its rows. */
|
|
2
|
+
export const BASE_URL = "https://api.specstream.ai";
|
|
3
|
+
export const USER_AGENT = "@specstream/openclaw-plugin/0.1.0";
|
|
4
|
+
/** A list runs for up to about five minutes, so the default wait is just under that. */
|
|
5
|
+
export const DEFAULT_WAIT_SECONDS = 300;
|
|
6
|
+
export const DEFAULT_POLL_INTERVAL_SECONDS = 3;
|
|
7
|
+
export const DEFAULT_LOCALE = "en-US";
|
|
8
|
+
export const MAX_RESULTS = 1000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 150_000;
|
|
10
|
+
const FINISHED = new Set(["completed", "partial"]);
|
|
11
|
+
const STOPPED = new Set(["failed", "cancelled", "canceled", "expired"]);
|
|
12
|
+
/** Fields every row carries. A list column with one of these names is renamed so it cannot overwrite them. */
|
|
13
|
+
const RESERVED = new Set(["list_id", "list_link", "position", "source_url", "checked_at"]);
|
|
14
|
+
/** SpecStream refused a call or answered with something this plugin cannot read. */
|
|
15
|
+
export class SpecStreamError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "SpecStreamError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
function dollars(cents) {
|
|
23
|
+
return `$${(cents / 100).toFixed(2)}`;
|
|
24
|
+
}
|
|
25
|
+
export function listLink(findId) {
|
|
26
|
+
return `https://specstream.ai/workspace/collections/${encodeURIComponent(findId)}`;
|
|
27
|
+
}
|
|
28
|
+
/** Flattens one record into a row: each column label maps to what the list shows, plus where the row was read. */
|
|
29
|
+
export function flattenRow(findId, record) {
|
|
30
|
+
const row = { list_id: findId, position: record.ordinal ?? null };
|
|
31
|
+
let source = null;
|
|
32
|
+
const cells = record.details?.length ? record.details : (record.cells ?? []);
|
|
33
|
+
for (const cell of cells) {
|
|
34
|
+
let label = String(cell.label || cell.column_id || "").trim();
|
|
35
|
+
if (!label)
|
|
36
|
+
continue;
|
|
37
|
+
if (RESERVED.has(label))
|
|
38
|
+
label = `${label} (column)`;
|
|
39
|
+
if (cell.absence)
|
|
40
|
+
row[label] = "";
|
|
41
|
+
else if (cell.display != null)
|
|
42
|
+
row[label] = cell.display;
|
|
43
|
+
else
|
|
44
|
+
row[label] = cell.value ?? "";
|
|
45
|
+
const provenance = cell.provenance || {};
|
|
46
|
+
if (source === null && String(provenance.source_url || "").startsWith("http"))
|
|
47
|
+
source = provenance;
|
|
48
|
+
}
|
|
49
|
+
row.source_url = source ? (source.source_url ?? "") : "";
|
|
50
|
+
row.checked_at = source ? (source.observed_at ?? "") : "";
|
|
51
|
+
row.list_link = listLink(findId);
|
|
52
|
+
return row;
|
|
53
|
+
}
|
|
54
|
+
async function sha256Hex(text) {
|
|
55
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
|
56
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
function isObject(value) {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function errorMessage(response, raw) {
|
|
62
|
+
let payload = {};
|
|
63
|
+
try {
|
|
64
|
+
payload = JSON.parse(raw || "{}");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
payload = {};
|
|
68
|
+
}
|
|
69
|
+
const detail = isObject(payload) ? payload.error : undefined;
|
|
70
|
+
const message = isObject(detail) ? detail.message : typeof detail === "string" ? detail : undefined;
|
|
71
|
+
return `SpecStream answered ${response.status}: ${message || response.statusText}`;
|
|
72
|
+
}
|
|
73
|
+
export class SpecStreamClient {
|
|
74
|
+
baseUrl;
|
|
75
|
+
apiKey;
|
|
76
|
+
fetchImpl;
|
|
77
|
+
sleep;
|
|
78
|
+
now;
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.apiKey = options.apiKey.trim();
|
|
81
|
+
this.baseUrl = (options.baseUrl ?? BASE_URL).replace(/\/+$/, "");
|
|
82
|
+
this.fetchImpl = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
83
|
+
this.sleep = options.sleep ?? wait;
|
|
84
|
+
this.now = options.now ?? Date.now;
|
|
85
|
+
}
|
|
86
|
+
async request(method, path, body, options = {}) {
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(new Error("timed out")), REQUEST_TIMEOUT_MS);
|
|
89
|
+
const outer = options.abortSignal;
|
|
90
|
+
const onAbort = () => controller.abort(outer?.reason);
|
|
91
|
+
if (outer?.aborted)
|
|
92
|
+
onAbort();
|
|
93
|
+
else
|
|
94
|
+
outer?.addEventListener("abort", onAbort, { once: true });
|
|
95
|
+
let response;
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
response = await this.fetchImpl(this.baseUrl + path, {
|
|
99
|
+
method,
|
|
100
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
101
|
+
headers: {
|
|
102
|
+
Authorization: /^bearer /i.test(this.apiKey) ? this.apiKey : `Bearer ${this.apiKey}`,
|
|
103
|
+
Accept: "application/json",
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
"User-Agent": USER_AGENT,
|
|
106
|
+
},
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
raw = await response.text();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (outer?.aborted)
|
|
113
|
+
throw error;
|
|
114
|
+
const reason = error instanceof Error ? (error.cause instanceof Error ? error.cause.message : error.message) : String(error);
|
|
115
|
+
throw new SpecStreamError(`Could not reach SpecStream: ${reason}`);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
outer?.removeEventListener("abort", onAbort);
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
if (options.allow404 && response.status === 404)
|
|
123
|
+
return null;
|
|
124
|
+
throw new SpecStreamError(errorMessage(response, raw));
|
|
125
|
+
}
|
|
126
|
+
let payload;
|
|
127
|
+
try {
|
|
128
|
+
payload = JSON.parse(raw || "{}");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
payload = null;
|
|
132
|
+
}
|
|
133
|
+
const data = isObject(payload) ? payload.data : undefined;
|
|
134
|
+
if (!isObject(data))
|
|
135
|
+
throw new SpecStreamError("SpecStream returned a response this plugin could not read.");
|
|
136
|
+
return data;
|
|
137
|
+
}
|
|
138
|
+
/** Every row found so far for a list, following the pages. Reading is free. */
|
|
139
|
+
async results(findId, abortSignal) {
|
|
140
|
+
const rows = [];
|
|
141
|
+
let cursor = null;
|
|
142
|
+
let state = "running";
|
|
143
|
+
for (;;) {
|
|
144
|
+
const query = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
|
|
145
|
+
const page = (await this.request("GET", `/v1/finds/${encodeURIComponent(findId)}/rows?${query}`, undefined, { abortSignal })) ?? {};
|
|
146
|
+
state = typeof page.status === "string" && page.status ? page.status : state;
|
|
147
|
+
for (const record of Array.isArray(page.rows) ? page.rows : [])
|
|
148
|
+
rows.push(flattenRow(findId, record));
|
|
149
|
+
const next = typeof page.next_cursor === "string" && page.next_cursor ? page.next_cursor : null;
|
|
150
|
+
if (!next || next === cursor)
|
|
151
|
+
break;
|
|
152
|
+
cursor = next;
|
|
153
|
+
}
|
|
154
|
+
return { list_id: findId, state, list_link: listLink(findId), row_count: rows.length, rows };
|
|
155
|
+
}
|
|
156
|
+
/** Reads the request, prices it, and runs it only if the most it can cost is within `maxPriceUsd`. */
|
|
157
|
+
async findList(request, options) {
|
|
158
|
+
const q = (request ?? "").trim();
|
|
159
|
+
if (!q)
|
|
160
|
+
throw new Error("Describe the list to find.");
|
|
161
|
+
const { maxResults, abortSignal } = options;
|
|
162
|
+
if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > MAX_RESULTS) {
|
|
163
|
+
throw new Error(`max_results must be a whole number from 1 to ${MAX_RESULTS}.`);
|
|
164
|
+
}
|
|
165
|
+
const maxCents = Math.round(Number(options.maxPriceUsd) * 100);
|
|
166
|
+
if (!(maxCents > 0))
|
|
167
|
+
throw new Error("The price ceiling must be greater than 0.");
|
|
168
|
+
// The key covers the hour, so an agent that retries a step reaches the run it already started and never buys the
|
|
169
|
+
// list twice. The previous hour is checked too, so a retry across the turn of the hour still finds that run.
|
|
170
|
+
const keyFor = async (at) => {
|
|
171
|
+
const hour = new Date(at).toISOString().slice(0, 13);
|
|
172
|
+
return `openclaw-${(await sha256Hex(`${q}|${maxResults}|${maxCents}|${hour}`)).slice(0, 32)}`;
|
|
173
|
+
};
|
|
174
|
+
const at = this.now();
|
|
175
|
+
const requestKey = await keyFor(at);
|
|
176
|
+
let existing = null;
|
|
177
|
+
for (const candidate of [requestKey, await keyFor(at - 3_600_000)]) {
|
|
178
|
+
existing = await this.request("GET", `/v1/capacity/requests/${encodeURIComponent(candidate)}`, undefined, {
|
|
179
|
+
allow404: true,
|
|
180
|
+
abortSignal,
|
|
181
|
+
});
|
|
182
|
+
if (existing?.find_id)
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
let findId;
|
|
186
|
+
let maximumCents;
|
|
187
|
+
if (existing?.find_id) {
|
|
188
|
+
findId = String(existing.find_id);
|
|
189
|
+
maximumCents = Math.trunc(Number(existing.maximum_cents)) || maxCents;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const reading = (await this.request("POST", "/v1/parse", { q, locale: options.locale ?? DEFAULT_LOCALE }, { abortSignal })) ?? {};
|
|
193
|
+
if (reading.status !== "runnable") {
|
|
194
|
+
const readAs = reading.canonical_sentence || q;
|
|
195
|
+
return {
|
|
196
|
+
state: "needs_edit",
|
|
197
|
+
request: q,
|
|
198
|
+
message: `SpecStream needs a clearer request before it can price it. It read: "${readAs}". Add the place or what every row must meet.`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const quote = (await this.request("POST", "/v1/capacity/quotes", { ast_hash: reading.ast_hash, operation: "find", requested_units: maxResults }, { abortSignal })) ?? {};
|
|
202
|
+
maximumCents = Math.trunc(Number(quote.maximum_cents)) || 0;
|
|
203
|
+
if (maximumCents > maxCents) {
|
|
204
|
+
return {
|
|
205
|
+
state: "over_maximum",
|
|
206
|
+
request: q,
|
|
207
|
+
maximum_price_usd: maximumCents / 100,
|
|
208
|
+
message: `Nothing ran: this list can cost up to ${dollars(maximumCents)}, above the ceiling of ${dollars(maxCents)}. Ask for fewer results, or tell the user the list needs a higher ceiling.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const run = (await this.request("POST", "/v1/capacity/executions", { quote_id: quote.quote_id, quote_hash: quote.quote_hash, confirm_max_cents: maximumCents, idempotency_key: requestKey }, { abortSignal })) ?? {};
|
|
212
|
+
if (!run.find_id)
|
|
213
|
+
throw new SpecStreamError("SpecStream returned a response this plugin could not read.");
|
|
214
|
+
findId = String(run.find_id);
|
|
215
|
+
}
|
|
216
|
+
const waitMs = Math.max(0, options.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
217
|
+
const pollMs = Math.max(0, options.pollIntervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000;
|
|
218
|
+
const deadline = this.now() + waitMs;
|
|
219
|
+
let result;
|
|
220
|
+
for (;;) {
|
|
221
|
+
result = await this.results(findId, abortSignal);
|
|
222
|
+
if (FINISHED.has(result.state) || STOPPED.has(result.state) || this.now() >= deadline)
|
|
223
|
+
break;
|
|
224
|
+
if (abortSignal?.aborted)
|
|
225
|
+
throw abortSignal.reason ?? new Error("aborted");
|
|
226
|
+
await this.sleep(pollMs);
|
|
227
|
+
}
|
|
228
|
+
const found = { ...result, request: q, maximum_price_usd: maximumCents / 100 };
|
|
229
|
+
if (!FINISHED.has(result.state) && !STOPPED.has(result.state)) {
|
|
230
|
+
found.message = "Still running. Call specstream_get_list_rows with this list_id for the rest. Reading is free.";
|
|
231
|
+
}
|
|
232
|
+
return found;
|
|
233
|
+
}
|
|
234
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DEFAULT_WAIT_SECONDS, MAX_RESULTS } from "./client.js";
|
|
2
|
+
export const DEFAULT_MAX_PRICE_USD = 5;
|
|
3
|
+
export const DEFAULT_MAX_RESULTS = 25;
|
|
4
|
+
export const MISSING_KEY_MESSAGE = "SpecStream has no API key. Set plugins.entries.specstream.config.apiKey (a SecretRef works) or the SPECSTREAM_API_KEY environment variable. " +
|
|
5
|
+
"Create a key under Agents & API in your SpecStream workspace.";
|
|
6
|
+
/** Reads the plugin config, falling back to SPECSTREAM_API_KEY for the key. Throws when no key is set. */
|
|
7
|
+
export function resolveConfig(config, env = process.env) {
|
|
8
|
+
const fromConfig = typeof config?.apiKey === "string" ? config.apiKey.trim() : "";
|
|
9
|
+
const apiKey = fromConfig || (env.SPECSTREAM_API_KEY ?? "").trim();
|
|
10
|
+
if (!apiKey)
|
|
11
|
+
throw new Error(MISSING_KEY_MESSAGE);
|
|
12
|
+
const maxPriceUsd = config?.maxPriceUsd ?? DEFAULT_MAX_PRICE_USD;
|
|
13
|
+
if (!Number.isFinite(maxPriceUsd) || maxPriceUsd <= 0) {
|
|
14
|
+
throw new Error("plugins.entries.specstream.config.maxPriceUsd must be greater than 0.");
|
|
15
|
+
}
|
|
16
|
+
return { apiKey, maxPriceUsd, waitSeconds: config?.waitSeconds ?? DEFAULT_WAIT_SECONDS };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The ceiling for one call. The model may ask for a lower ceiling than the configured one and never a higher one,
|
|
20
|
+
* because the configured value is the limit the person agreed to.
|
|
21
|
+
*/
|
|
22
|
+
export function effectiveCeiling(configuredUsd, requestedUsd) {
|
|
23
|
+
if (requestedUsd === undefined || !Number.isFinite(requestedUsd) || requestedUsd <= 0)
|
|
24
|
+
return configuredUsd;
|
|
25
|
+
return Math.min(configuredUsd, requestedUsd);
|
|
26
|
+
}
|
|
27
|
+
export function clampResults(requested) {
|
|
28
|
+
const whole = Math.trunc(Number(requested ?? DEFAULT_MAX_RESULTS));
|
|
29
|
+
if (!Number.isFinite(whole) || whole < 1)
|
|
30
|
+
return DEFAULT_MAX_RESULTS;
|
|
31
|
+
return Math.min(whole, MAX_RESULTS);
|
|
32
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
3
|
+
import { SpecStreamClient, MAX_RESULTS } from "./client.js";
|
|
4
|
+
import { clampResults, effectiveCeiling, resolveConfig, DEFAULT_MAX_PRICE_USD } from "./config.js";
|
|
5
|
+
const configSchema = Type.Object({
|
|
6
|
+
apiKey: Type.Optional(Type.Union([Type.String(), Type.Record(Type.String(), Type.Unknown())], {
|
|
7
|
+
description: "SpecStream API key, or a SecretRef. Falls back to the SPECSTREAM_API_KEY environment variable.",
|
|
8
|
+
})),
|
|
9
|
+
maxPriceUsd: Type.Optional(Type.Number({
|
|
10
|
+
exclusiveMinimum: 0,
|
|
11
|
+
description: `The most one list may cost, in US dollars. Defaults to ${DEFAULT_MAX_PRICE_USD}. The model can lower it per call and never raise it.`,
|
|
12
|
+
})),
|
|
13
|
+
waitSeconds: Type.Optional(Type.Integer({ minimum: 10, maximum: 900, description: "How long specstream_find_list waits for a running list. Defaults to 300." })),
|
|
14
|
+
}, { additionalProperties: false });
|
|
15
|
+
export default defineToolPlugin({
|
|
16
|
+
id: "specstream",
|
|
17
|
+
name: "SpecStream",
|
|
18
|
+
description: "Ask for any list in plain words and get every matching row from the live web, each with its source page, priced per match.",
|
|
19
|
+
activation: { onStartup: false },
|
|
20
|
+
configSchema,
|
|
21
|
+
tools: (tool) => [
|
|
22
|
+
tool({
|
|
23
|
+
name: "specstream_find_list",
|
|
24
|
+
label: "Find a list",
|
|
25
|
+
description: [
|
|
26
|
+
"Find a list of real things on the live web and return every matching row, each with the page it was read from.",
|
|
27
|
+
"Use it when the answer is many things that meet conditions: companies in a city, local businesses with a service, funding filings, open grants, tenders, clinical trials, repositories, people in a role.",
|
|
28
|
+
"For one fact or a summary of one page, use a search tool instead.",
|
|
29
|
+
"It is priced per match. Rows that could not be confirmed are left out and not charged. The run starts only if the quoted maximum is within the user's price ceiling.",
|
|
30
|
+
"Returns JSON with state, list_id, list_link and rows (one object per match: its columns, source_url and checked_at).",
|
|
31
|
+
'state "over_maximum" means nothing ran because the price was above the ceiling: ask for fewer rows or tell the user.',
|
|
32
|
+
'state "needs_edit" means the request was too vague to price: add the place or the conditions.',
|
|
33
|
+
"If the list is still running, call specstream_get_list_rows with its list_id for the rest.",
|
|
34
|
+
].join(" "),
|
|
35
|
+
parameters: Type.Object({
|
|
36
|
+
request: Type.String({
|
|
37
|
+
minLength: 1,
|
|
38
|
+
description: "The list in plain words: what to find, where, what every row must meet, and the facts to bring back. " +
|
|
39
|
+
'Example: "Dental clinics in Lisbon with online booking, with their website and phone".',
|
|
40
|
+
}),
|
|
41
|
+
max_results: Type.Optional(Type.Integer({
|
|
42
|
+
minimum: 1,
|
|
43
|
+
maximum: MAX_RESULTS,
|
|
44
|
+
description: "How many rows to find, 1 to 1000. Defaults to 25. Ask for what the task needs, because the price grows with the matches.",
|
|
45
|
+
})),
|
|
46
|
+
max_price_usd: Type.Optional(Type.Number({
|
|
47
|
+
exclusiveMinimum: 0,
|
|
48
|
+
description: "A lower price ceiling for this call, in US dollars. A value above the user's configured ceiling is ignored.",
|
|
49
|
+
})),
|
|
50
|
+
}),
|
|
51
|
+
execute: async ({ request, max_results, max_price_usd }, config, { signal }) => {
|
|
52
|
+
const settings = resolveConfig(config);
|
|
53
|
+
const client = new SpecStreamClient({ apiKey: settings.apiKey });
|
|
54
|
+
return client.findList(request, {
|
|
55
|
+
maxResults: clampResults(max_results),
|
|
56
|
+
maxPriceUsd: effectiveCeiling(settings.maxPriceUsd, max_price_usd),
|
|
57
|
+
waitSeconds: settings.waitSeconds,
|
|
58
|
+
abortSignal: signal,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
tool({
|
|
63
|
+
name: "specstream_get_list_rows",
|
|
64
|
+
label: "Read list rows",
|
|
65
|
+
description: [
|
|
66
|
+
"Read every row found so far for a SpecStream list the user already owns, such as one that was still running. Reading is free.",
|
|
67
|
+
"Returns JSON with state, list_id, list_link and rows, each with its columns, source_url and checked_at.",
|
|
68
|
+
].join(" "),
|
|
69
|
+
parameters: Type.Object({
|
|
70
|
+
list_id: Type.String({
|
|
71
|
+
minLength: 1,
|
|
72
|
+
description: 'The list_id that specstream_find_list returned, like "fnd_gk5y3d0r97bvyz0t08zzh75kr6".',
|
|
73
|
+
}),
|
|
74
|
+
}),
|
|
75
|
+
execute: async ({ list_id }, config, { signal }) => {
|
|
76
|
+
const settings = resolveConfig(config);
|
|
77
|
+
return new SpecStreamClient({ apiKey: settings.apiKey }).results(list_id.trim(), signal);
|
|
78
|
+
},
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "specstream",
|
|
3
|
+
"name": "SpecStream",
|
|
4
|
+
"description": "Ask for any list in plain words and get every matching row from the live web, each with its source page, priced per match.",
|
|
5
|
+
"categories": [
|
|
6
|
+
"data-analytics"
|
|
7
|
+
],
|
|
8
|
+
"version": "0.1.0",
|
|
9
|
+
"configSchema": {
|
|
10
|
+
"type": "object",
|
|
11
|
+
"properties": {
|
|
12
|
+
"apiKey": {
|
|
13
|
+
"anyOf": [
|
|
14
|
+
{
|
|
15
|
+
"type": "string"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "object",
|
|
19
|
+
"patternProperties": {
|
|
20
|
+
"^.*$": {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"description": "SpecStream API key, or a SecretRef. Falls back to the SPECSTREAM_API_KEY environment variable."
|
|
25
|
+
},
|
|
26
|
+
"maxPriceUsd": {
|
|
27
|
+
"type": "number",
|
|
28
|
+
"exclusiveMinimum": 0,
|
|
29
|
+
"description": "The most one list may cost, in US dollars. Defaults to 5. The model can lower it per call and never raise it."
|
|
30
|
+
},
|
|
31
|
+
"waitSeconds": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 10,
|
|
34
|
+
"maximum": 900,
|
|
35
|
+
"description": "How long specstream_find_list waits for a running list. Defaults to 300."
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"additionalProperties": false
|
|
39
|
+
},
|
|
40
|
+
"activation": {
|
|
41
|
+
"onStartup": false
|
|
42
|
+
},
|
|
43
|
+
"skills": [
|
|
44
|
+
"./skills"
|
|
45
|
+
],
|
|
46
|
+
"uiHints": {
|
|
47
|
+
"apiKey": {
|
|
48
|
+
"label": "SpecStream API key",
|
|
49
|
+
"help": "Create a key under Agents & API in your SpecStream workspace. Falls back to the SPECSTREAM_API_KEY environment variable.",
|
|
50
|
+
"sensitive": true,
|
|
51
|
+
"placeholder": "ss_live_..."
|
|
52
|
+
},
|
|
53
|
+
"maxPriceUsd": {
|
|
54
|
+
"label": "Price ceiling per list (USD)",
|
|
55
|
+
"help": "A list runs only if its quoted maximum is at or below this. The model can lower it per call and never raise it. Default 5."
|
|
56
|
+
},
|
|
57
|
+
"waitSeconds": {
|
|
58
|
+
"label": "Wait for a running list (seconds)",
|
|
59
|
+
"help": "How long a find waits before returning the rows so far. Default 300.",
|
|
60
|
+
"advanced": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"configContracts": {
|
|
64
|
+
"secretInputs": {
|
|
65
|
+
"paths": [
|
|
66
|
+
{
|
|
67
|
+
"path": "apiKey",
|
|
68
|
+
"expected": "string",
|
|
69
|
+
"ownerKind": "capability"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"contracts": {
|
|
75
|
+
"tools": [
|
|
76
|
+
"specstream_find_list",
|
|
77
|
+
"specstream_get_list_rows"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specstream/openclaw-plugin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SpecStream plugin for OpenClaw: ask for any list in plain words and get every matching row from the live web, each with its source page, priced per match.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"skills",
|
|
9
|
+
"assets",
|
|
10
|
+
"openclaw.plugin.json",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"openclaw",
|
|
16
|
+
"openclaw-plugin",
|
|
17
|
+
"specstream",
|
|
18
|
+
"lead-lists",
|
|
19
|
+
"web-data",
|
|
20
|
+
"live-web",
|
|
21
|
+
"agent-tools",
|
|
22
|
+
"mcp"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/Specstreamai/specstream-tools.git",
|
|
28
|
+
"directory": "integrations/openclaw"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://specstream.ai/docs/mcp",
|
|
31
|
+
"author": {
|
|
32
|
+
"name": "SpecStream",
|
|
33
|
+
"url": "https://specstream.ai"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc -p tsconfig.json",
|
|
40
|
+
"check": "tsc -p tsconfig.test.json",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
|
|
43
|
+
"plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
|
|
44
|
+
"prepack": "npm run plugin:build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"typebox": "1.3.34"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"openclaw": ">=2026.10.3"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"openclaw": "2026.10.3",
|
|
55
|
+
"typescript": "5.9.3",
|
|
56
|
+
"vitest": "^3.2.0"
|
|
57
|
+
},
|
|
58
|
+
"openclaw": {
|
|
59
|
+
"extensions": [
|
|
60
|
+
"./dist/index.js"
|
|
61
|
+
],
|
|
62
|
+
"compat": {
|
|
63
|
+
"pluginApi": ">=2026.10.3"
|
|
64
|
+
},
|
|
65
|
+
"build": {
|
|
66
|
+
"openclawVersion": "2026.10.3"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: specstream-approve-work
|
|
3
|
+
description: Approve an exact price, up to the maximum the person set. This starts paid work.
|
|
4
|
+
metadata: { "openclaw": { "requires": { "config": ["mcp.servers.specstream"] } } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Approve a price
|
|
8
|
+
|
|
9
|
+
SpecStream is the live web data engine for teams and agents. Use the configured MCP server at `https://api.specstream.ai/mcp`.
|
|
10
|
+
|
|
11
|
+
`authorize_capacity_work` starts paid work, so treat it as a write. It needs the reviewed `quote_id` and `quote_hash`, the approved maximum as an integer in `confirm_max_cents`, and a stable `idempotency_key`. A standing project or agent authority must cover this exact scope and limit. A funded account is not enough.
|
|
12
|
+
|
|
13
|
+
Save the same key before you send the call. If the response is unclear, recover the same work with `get_capacity_request` or `get_capacity_quote`. Never create a new key, price or run just to recover a lost response. A price that expired or changed needs a new approval.
|
|
14
|
+
|
|
15
|
+
Do not top up, subscribe, add a seat or open a payment page. If the balance or the authority is missing, say exactly what is missing and keep the request.
|
|
16
|
+
|
|
17
|
+
Use `get_find` for progress. Rows appear while the run works, and a run can be stopped and keeps what it found. Only delivered matches are charged. Unsure results stay unsure.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: specstream-compile
|
|
3
|
+
description: Read a plain-English request back to the person before anything runs. No price, no work.
|
|
4
|
+
metadata: { "openclaw": { "requires": { "config": ["mcp.servers.specstream"] } } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Read the request
|
|
8
|
+
|
|
9
|
+
SpecStream is the live web data engine for teams and agents. Use the configured MCP server at `https://api.specstream.ai/mcp`.
|
|
10
|
+
|
|
11
|
+
Use `compile` to read the person's request. Say back what it found: the kind of thing, the conditions, the details to bring back and the quantity. Separate the conditions that must hold from the details that are nice to have. Use `edit_spec` for a deliberate change. Never drop a hard condition quietly, and never treat missing evidence as a no. Reading a request does not start any work.
|
|
12
|
+
|
|
13
|
+
Suggest requests that work well: local businesses with ratings, companies from registries, grants, tenders, clinical trials, papers, datasets, regulations, vulnerabilities, apps, repositories, packages, podcasts and books. Companies that use a tool, funding and new-executive signals, US nonprofits and UK financial firms by FCA status also work. If someone asks for people by job title, say that results may be few. `list_request_templates` returns requests that already work for each kind.
|
|
14
|
+
|
|
15
|
+
The same saved request can be revised, branched, compared and shared, in the workspace or through this agent. Never ask for an API key in chat. Use the connector's own sign-in.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: specstream-continue-work
|
|
3
|
+
description: Read the finished rows with the page behind each fact, send them where the person works, and start new work only on purpose.
|
|
4
|
+
metadata: { "openclaw": { "requires": { "config": ["mcp.servers.specstream"] } } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Read, send and continue
|
|
8
|
+
|
|
9
|
+
SpecStream is the live web data engine for teams and agents. Use the configured MCP server at `https://api.specstream.ai/mcp`.
|
|
10
|
+
|
|
11
|
+
Read `list_find_rows` and follow each `next_cursor` through the `cursor` argument until it is null. The first page is not the whole result. Open `get_find_decisions` to show each condition check with the value observed, its source and the time it was read. Never invent links or fill in unknown facts. Keep the original read times and do not call reused evidence fresh.
|
|
12
|
+
|
|
13
|
+
When the person asks, `send_to_destination` sends selected results to Google Sheets, Excel, Notion, Airtable, HubSpot, Salesforce or an Outlook draft. An Outlook draft lands in the person's own Drafts folder and is never sent. `export_find` makes a download link as CSV, Excel, JSON or HTML. Reading, exporting and sharing cost nothing.
|
|
14
|
+
|
|
15
|
+
Finding more, adding facts or rechecking is new work with its own reviewed price. Rechecks cost half.
|
|
16
|
+
|
|
17
|
+
`create_capacity_schedule` sets up a monitor and is a write. It needs a scope, a cadence, a validity period and limits per run and per period. A monitor reruns on its schedule and labels each change: still matches, no longer matches, unresolved or unavailable. Creating one does not fund it. A blocked or missed run is not an unchanged result and is not bought later. Pause or revoke a monitor when the owner says so.
|
|
18
|
+
|
|
19
|
+
The workspace is the place to explore the full history. Do not present a small result widget as the whole workspace.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: specstream-review-work
|
|
3
|
+
description: Get the real price for the exact request. Nothing runs.
|
|
4
|
+
metadata: { "openclaw": { "requires": { "config": ["mcp.servers.specstream"] } } }
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# See the price
|
|
8
|
+
|
|
9
|
+
SpecStream is the live web data engine for teams and agents. Use the configured MCP server at `https://api.specstream.ai/mcp`.
|
|
10
|
+
|
|
11
|
+
Call `quote_capacity_work` with the `ast_hash` from `compile`, the operation, the number of results wanted, the useful minimum, and the original `parent_find_id`, `project_id` or `revision_id` when one applies. Asking for more uses `extend`, adding facts uses `add_facts` and rechecking uses `refresh`.
|
|
12
|
+
|
|
13
|
+
Say the scope, the conditions, the maximum price and the expiry in text. Results cost 10 cents, 30 cents or $1 each, plus 10 cents for a person's email and 25 cents for a phone, and nothing when nothing matches. A price lasts 30 minutes. The price does not spend any balance and does not start a job.
|
|
14
|
+
|
|
15
|
+
Use `get_capacity_balance` to read the balance. A balance is not permission to spend. Do not copy the maximum into an approval unless the person said to, or an existing bounded task already allows it. Plans and top-ups live in the person's account, not in chat.
|