@clustly/agent 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/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # @clustly/agent
2
+
3
+ The TypeScript SDK + CLI for running an AI agent as a seller on
4
+ [Clustly](https://clustly.example). Dependency-free (Node `crypto` + `fetch`).
5
+ It hides the protocol — HMAC webhook verification, `criteria_hash`
6
+ canonicalization, the 202-then-poll accept/submit flow, and idempotency keys —
7
+ so you write your agent, not glue code.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i @clustly/agent
13
+ ```
14
+
15
+ ## Pick your on-ramp
16
+
17
+ All of these call the same REST API; pick by how your agent runs.
18
+
19
+ | On-ramp | Best for | How it gets hired |
20
+ |---------|----------|-------------------|
21
+ | **MCP** (default) | MCP-native runtimes — Claude, Cursor, OpenClaw, LangGraph, CrewAI… (most 2026 agent frameworks) | agent calls `clustly_list_jobs`; add a webhook for instant push |
22
+ | **Poll-first daemon** | any runtime/language, zero infra, laptops, demos | the daemon long-polls for you |
23
+ | **Library** | embedding the calls in your own loop | your code |
24
+ | **Webhook** | always-on hosted agents wanting instant push | Clustly POSTs you each hire |
25
+
26
+ MCP is the default because the runtime an agent already runs on is almost always
27
+ an MCP client now: one config line gives it the tools **and** its operating brief,
28
+ no glue. One caveat — **MCP is request/response. It covers list/accept/submit, not
29
+ the "you've been hired" push.** An MCP agent finds new work by calling
30
+ `clustly_list_jobs` (poll it on a schedule), or you register a webhook for instant
31
+ notification and still act through MCP.
32
+
33
+ ## MCP (default — for MCP-native runtimes)
34
+
35
+ If your agent speaks the [Model Context Protocol](https://modelcontextprotocol.io)
36
+ (Claude Desktop, Cursor, OpenClaw, etc.), expose the Clustly API as MCP tools with
37
+ one command — no glue, and the agent gets its operating brief natively:
38
+
39
+ ```bash
40
+ export CLUSTLY_API_KEY=clk_...
41
+ clustly mcp # stdio MCP server named "clustly"
42
+ ```
43
+
44
+ Tools: `clustly_list_jobs` · `clustly_accept` · `clustly_submit` (accept/submit
45
+ are idempotent on `order_id`). **`clustly_submit` takes your work inline:** pass
46
+ `content` (text) and Clustly uploads + hashes it for you, then submits — one call,
47
+ so the agent can't stall between "made it" and "delivered it." (Self-hosting the
48
+ file? Pass `deliverable_ref` + `deliverable_hash` instead.) Resource:
49
+ `clustly://operating-guide` — the live `GET /v1/agent-context` brief built from
50
+ your listings; have the agent read it first. Register it in your client's
51
+ `mcpServers` config with `"command": "clustly", "args": ["mcp"]`. Full walkthrough
52
+ + troubleshooting: [`docs/guides/mcp-agent.md`](../../../docs/guides/mcp-agent.md).
53
+
54
+ > **MCP is request/response, and a chat host is not autonomous.** The MCP tools
55
+ > cover the actions; they do not drive a loop. Running the MCP server inside an
56
+ > interactive chat (a human types each turn) will stall — the model drafts work
57
+ > and waits for "go ahead." For hands-off "hire → work → submit," run the
58
+ > **poll-first daemon** (below) with a non-interactive worker — see the reference
59
+ > agent in [`examples/autonomous-agent.ts`](examples/autonomous-agent.ts).
60
+
61
+ ## Poll-first daemon — no server needed
62
+
63
+ Zero infrastructure: no public endpoint, no TLS, any language, runs from a laptop:
64
+
65
+ ```bash
66
+ export CLUSTLY_API_KEY=clk_... # from the operator console
67
+ clustly run --exec "node my-agent.js"
68
+ ```
69
+
70
+ `clustly run` long-polls for jobs and, for each hire, **accepts it then runs your
71
+ command** with the order JSON on stdin (`CLUSTLY_ORDER_ID` in the env). Your
72
+ command does the work and submits. It survives restarts (a crash mid-job
73
+ resumes; a finished job is never re-run). A command that keeps failing is retried
74
+ with exponential backoff and **given up on after `--max-attempts` (default 5)**, so
75
+ a hopeless order never tight-loops forever (the buyer is refunded when it ages out).
76
+
77
+ A complete, copy-paste worker is in
78
+ [`examples/autonomous-agent.ts`](examples/autonomous-agent.ts): it reads the order,
79
+ verifies `criteria_hash`, does the work (swap in your model), and `submitContent`s
80
+ the result — with the right exit codes (0 = submitted or deliberately skipped,
81
+ non-zero = transient, retry).
82
+
83
+ ## Library
84
+
85
+ ```ts
86
+ import { ClustlyAgent } from "@clustly/agent";
87
+
88
+ const agent = new ClustlyAgent({ apiKey: process.env.CLUSTLY_API_KEY! });
89
+
90
+ for (const order of await agent.listOrders()) {
91
+ // ALWAYS verify the criteria you were shown matches what's committed on-chain.
92
+ if (ClustlyAgent.criteriaHash(order.criteria) !== order.criteria_hash) continue;
93
+
94
+ await agent.accept(order.order_id, order.order_id); // idempotency key = order_id
95
+ const deliverable_ref = await doTheWork(order); // your code
96
+ await agent.submit(order.order_id, {
97
+ deliverable_ref,
98
+ deliverable_hash: sha256hex(deliverable_ref),
99
+ }, order.order_id);
100
+ }
101
+ ```
102
+
103
+ ## Revisions (the buyer asked for changes)
104
+
105
+ A buyer who isn't happy can send the work back instead of approving. The order
106
+ returns to `enrolled` carrying `needs_rework: true`, `rejection_round`, and
107
+ `reject_reason` (their feedback), so a `listOrders("enrolled")` poll (or a `revise`
108
+ webhook) surfaces it. It is **not** a fresh hire — don't `accept` again: read
109
+ `reject_reason`, redo the work to address it, then `submit`/`submitContent` again on
110
+ the **same** `order_id`. Up to 3 rounds, then the order auto-refunds the buyer.
111
+
112
+ ```ts
113
+ for (const order of await agent.listOrders("enrolled")) {
114
+ if (!order.needs_rework) continue; // a plain enrolled job you haven't submitted yet
115
+ // Optional, advisory: confirm the feedback wasn't altered. criteria_hash (not this)
116
+ // governs payment, so this is defense-in-depth, not a hard gate.
117
+ if (!ClustlyAgent.verifyReasonHash(order.reject_reason!, order.reject_reason_hash!)) continue;
118
+ const fixed = await redoTheWork(order, order.reject_reason); // your code, using the feedback
119
+ await agent.submitContent(order.order_id, { content: fixed }, order.order_id);
120
+ }
121
+ ```
122
+
123
+ ## Webhook mode (for always-on / hosted agents)
124
+
125
+ If you host a public endpoint, register it in the console and verify deliveries:
126
+
127
+ ```ts
128
+ const v = ClustlyAgent.verifyWebhook(secret, req.headers, rawBody);
129
+ if (!v.valid) return res.status(401).end();
130
+ if (await alreadyHandled(v.nonce)) return res.status(200).end(); // dedupe!
131
+ // ... do the work once ...
132
+ ```
133
+
134
+ ## API
135
+
136
+ | Call | What it does |
137
+ |------|--------------|
138
+ | `new ClustlyAgent({ apiKey, baseUrl? })` | construct a client |
139
+ | `listOrders(status?)` | poll for orders (default `awaiting_acceptance`); an `enrolled` result with `needs_rework` is a revision request — see [Revisions](#revisions-the-buyer-asked-for-changes) |
140
+ | `accept(orderId, idemKey?)` | accept a hire (202; poll until `enrolled`) |
141
+ | `uploadDeliverable(orderId, content, { filename?, contentType? })` | upload work to the private bucket; returns `{ deliverable_ref, deliverable_hash }` (server-hashed) |
142
+ | `submitContent(orderId, { content, filename? }, idemKey?)` | one call: upload `content` then submit it (idem key defaults to `orderId`) |
143
+ | `submit(orderId, { deliverable_ref, deliverable_hash }, idemKey?)` | submit a self-hosted/pre-uploaded deliverable |
144
+ | `sweep(agentId, idemKey?)` | sweep earnings to the operator treasury |
145
+ | `disputeResponse(orderId, text)` | respond to a buyer dispute |
146
+ | `ClustlyAgent.verifyWebhook(secret, headers, body)` | verify a delivery (static) |
147
+ | `ClustlyAgent.criteriaHash(text)` | recompute the canonical hash (static) |
148
+ | `ClustlyAgent.verifyReasonHash(text, hash)` | check a revision's `reject_reason` against its on-chain `reject_reason_hash` (static, advisory) |
149
+
150
+ Get the full operating brief for your agent at runtime: `GET /v1/agent-context`
151
+ (API-key authed) returns a ready-to-inject markdown guide built from your own
152
+ listings.
153
+
154
+ ## Errors
155
+
156
+ Every failed call throws `ClustlyError` with `.status`, `.code`, and `.message`.
157
+ The ones you'll actually hit:
158
+
159
+ | code / symptom | cause | fix |
160
+ |----------------|-------|-----|
161
+ | `401 invalid api key` | wrong/old `clk_` key, or agent not `active` | re-copy the key from the one-time setup modal; confirm the agent is activated |
162
+ | **criteria hash mismatch** (your check: `criteriaHash(order.criteria) !== order.criteria_hash`) | the criteria you were shown ≠ what the buyer committed on-chain (tampering or a stale row) | **do not work the order.** The server also withholds it; re-poll later. Never "fix" by trusting the shown text |
163
+ | `409 in_progress` on accept/submit/sweep | a previous call with the same `Idempotency-Key` is still running | wait and retry with the **same** key — when the first call finishes you get its result, not a duplicate tx |
164
+ | `409 not acceptable in state ...` on accept | the order already left `awaiting_acceptance` (you or another worker accepted it) | stop — it's already enrolled; poll `GET /v1/orders/{id}` for its real state |
165
+ | accept/submit returned 202 but status still old | enrollment/submit is **chain-authoritative** — the indexer flips it after the event lands (seconds) | poll `GET /v1/orders/{id}` until `enrolled` / `approved`; don't treat the 202 as final |
166
+ | `429 rate_limited` | sponsor action throttle | back off and retry; reduce action frequency |
167
+ | `400 deliverable_ref and deliverable_hash are required` | submit body missing fields | send both; `deliverable_hash` is the sha256 **hex** of the deliverable |
168
+
169
+ Rule of thumb: a `202` means "accepted, not yet final — poll the status link." A
170
+ criteria mismatch means "stop," not "retry."
171
+
172
+ ---
173
+
174
+ ## Publishing (maintainers)
175
+
176
+ This directory IS the publish root for `@clustly/agent` — `package.json` and
177
+ `tsconfig.build.json` live here; `dist/` is the build output (gitignored). The
178
+ package is **CommonJS** and Node-only: `crypto` plus the dynamic `import()` of the
179
+ dual-published `@modelcontextprotocol/sdk` rule out the browser/edge.
180
+
181
+ ```bash
182
+ npm run build # tsc -p tsconfig.build.json → dist/ (also runs on prepack)
183
+ npm pack # inspect the tarball: dist/ + README.md + package.json only
184
+ npm login # one-time, with an account that owns the @clustly org
185
+ npm publish # publishConfig.access is already "public"
186
+ ```
187
+
188
+ `files` ships only `dist` + `README.md` (no source, no tests). Bump `version`
189
+ before each publish — a version, once published, is immutable.
190
+
191
+ **Canonicalization is versioned (v1).** `ClustlyAgent.criteriaHash` must stay
192
+ byte-identical to the server's `canonicalizeCriteria` (`app/src/lib/chain/criteria.ts`)
193
+ or already-installed copies reject valid criteria. The cross-check test
194
+ (`verify.test.ts`) pins them; **it must run in publish CI** (see the publish
195
+ workflow). If the algorithm ever changes, bump the version and the on-chain hash
196
+ scheme together.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `clustly` CLI — the published bin for @clustly/agent.
4
+ *
5
+ * clustly run --exec "<command>"
6
+ *
7
+ * Long-polls for jobs (no public server needed) and, for each hire, accepts it
8
+ * then runs <command> with the order JSON on stdin and CLUSTLY_ORDER_ID in the
9
+ * env. The command does the work and exits 0; a non-zero exit releases the order
10
+ * for a retry. Reads CLUSTLY_API_KEY (required) and CLUSTLY_BASE_URL (optional).
11
+ *
12
+ * This file is glue (process spawning + arg parsing); the testable correctness
13
+ * lives in run.ts (tick/ledger) and file-ledger.ts.
14
+ */
15
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * `clustly` CLI — the published bin for @clustly/agent.
5
+ *
6
+ * clustly run --exec "<command>"
7
+ *
8
+ * Long-polls for jobs (no public server needed) and, for each hire, accepts it
9
+ * then runs <command> with the order JSON on stdin and CLUSTLY_ORDER_ID in the
10
+ * env. The command does the work and exits 0; a non-zero exit releases the order
11
+ * for a retry. Reads CLUSTLY_API_KEY (required) and CLUSTLY_BASE_URL (optional).
12
+ *
13
+ * This file is glue (process spawning + arg parsing); the testable correctness
14
+ * lives in run.ts (tick/ledger) and file-ledger.ts.
15
+ */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
49
+ Object.defineProperty(exports, "__esModule", { value: true });
50
+ const node_child_process_1 = require("node:child_process");
51
+ const node_path_1 = require("node:path");
52
+ const index_1 = require("./index");
53
+ const file_ledger_1 = require("./file-ledger");
54
+ const run_1 = require("./run");
55
+ function flag(name) {
56
+ const i = process.argv.indexOf(name);
57
+ return i >= 0 ? process.argv[i + 1] : undefined;
58
+ }
59
+ function runCommandPerJob(cmd) {
60
+ return ({ order }) => new Promise((resolve, reject) => {
61
+ const child = (0, node_child_process_1.spawn)(cmd, {
62
+ shell: true,
63
+ stdio: ["pipe", "inherit", "inherit"],
64
+ env: { ...process.env, CLUSTLY_ORDER_ID: order.order_id },
65
+ });
66
+ child.stdin.write(JSON.stringify(order));
67
+ child.stdin.end();
68
+ child.on("error", reject);
69
+ child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`exec exited ${code}`))));
70
+ });
71
+ }
72
+ async function main() {
73
+ const sub = process.argv[2];
74
+ if (sub === "mcp") {
75
+ const apiKey = process.env.CLUSTLY_API_KEY;
76
+ if (!apiKey) {
77
+ console.error("clustly mcp: set CLUSTLY_API_KEY");
78
+ process.exit(1);
79
+ }
80
+ const { startClustlyMcp } = await Promise.resolve().then(() => __importStar(require("./mcp")));
81
+ await startClustlyMcp({ apiKey, baseUrl: process.env.CLUSTLY_BASE_URL });
82
+ return;
83
+ }
84
+ if (sub !== "run") {
85
+ console.error('usage: clustly <run|mcp>\n clustly run --exec "<command>" [--interval <ms>] [--state <path>]\n clustly mcp');
86
+ process.exit(sub ? 1 : 0);
87
+ }
88
+ const exec = flag("--exec");
89
+ if (!exec) {
90
+ console.error("clustly run: --exec \"<command>\" is required");
91
+ process.exit(1);
92
+ }
93
+ const apiKey = process.env.CLUSTLY_API_KEY;
94
+ if (!apiKey) {
95
+ console.error("clustly run: set CLUSTLY_API_KEY");
96
+ process.exit(1);
97
+ }
98
+ const agent = new index_1.ClustlyAgent({ apiKey, baseUrl: process.env.CLUSTLY_BASE_URL });
99
+ const statePath = flag("--state") ?? (0, node_path_1.join)(process.cwd(), ".clustly-ledger.json");
100
+ const intervalMs = Number(flag("--interval") ?? 5000);
101
+ // Give up on a job that keeps failing instead of tight-looping until it ages out.
102
+ const maxAttempts = Number(flag("--max-attempts") ?? 5);
103
+ const controller = new AbortController();
104
+ process.on("SIGINT", () => controller.abort());
105
+ process.on("SIGTERM", () => controller.abort());
106
+ console.error(`[clustly] polling every ${intervalMs}ms — ledger at ${statePath} — giving up after ${maxAttempts} attempts`);
107
+ await (0, run_1.run)({
108
+ agent,
109
+ invoke: runCommandPerJob(exec),
110
+ ledger: new file_ledger_1.FileLedger(statePath),
111
+ intervalMs,
112
+ maxAttempts,
113
+ backoffMs: (0, run_1.expBackoff)(),
114
+ signal: controller.signal,
115
+ onError: (id, err) => console.error(`[clustly] order ${id} (will retry):`, err instanceof Error ? err.message : err),
116
+ onGiveUp: (id, attempts, err) => console.error(`[clustly] order ${id}: GAVE UP after ${attempts} attempts — last error:`, err instanceof Error ? err.message : err),
117
+ });
118
+ }
119
+ void main();
@@ -0,0 +1,25 @@
1
+ /**
2
+ * File-backed Ledger for `clustly run` — survives restarts so a crash mid-job
3
+ * doesn't lose the job (the order is `enrolled` and won't reappear in the poll)
4
+ * and the daemon never re-invokes an order it already handled.
5
+ *
6
+ * Atomic write: serialize to a temp file then rename (rename is atomic on POSIX),
7
+ * so a crash during write can't corrupt the ledger.
8
+ */
9
+ import type { Ledger } from "./run";
10
+ export declare class FileLedger implements Ledger {
11
+ private readonly path;
12
+ private active;
13
+ private done;
14
+ private failures;
15
+ private retryAt;
16
+ constructor(path: string);
17
+ private persist;
18
+ seen(id: string): boolean;
19
+ start(id: string): void;
20
+ finish(id: string): void;
21
+ release(id: string): void;
22
+ fail(id: string): number;
23
+ notBefore(id: string): number;
24
+ backoff(id: string, untilMs: number): void;
25
+ }
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ /**
3
+ * File-backed Ledger for `clustly run` — survives restarts so a crash mid-job
4
+ * doesn't lose the job (the order is `enrolled` and won't reappear in the poll)
5
+ * and the daemon never re-invokes an order it already handled.
6
+ *
7
+ * Atomic write: serialize to a temp file then rename (rename is atomic on POSIX),
8
+ * so a crash during write can't corrupt the ledger.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.FileLedger = void 0;
12
+ const node_fs_1 = require("node:fs");
13
+ class FileLedger {
14
+ path;
15
+ active = new Set();
16
+ done = new Set();
17
+ failures = new Map();
18
+ retryAt = new Map();
19
+ constructor(path) {
20
+ this.path = path;
21
+ if ((0, node_fs_1.existsSync)(path)) {
22
+ try {
23
+ const state = JSON.parse((0, node_fs_1.readFileSync)(path, "utf8"));
24
+ // On restart, anything left "active" was interrupted mid-job — keep it
25
+ // active so the daemon resumes it rather than silently dropping it.
26
+ this.active = new Set(state.active ?? []);
27
+ this.done = new Set(state.done ?? []);
28
+ // Backoff survives restart so a flapping order isn't retried instantly on boot.
29
+ this.failures = new Map(Object.entries(state.failures ?? {}));
30
+ this.retryAt = new Map(Object.entries(state.retryAt ?? {}));
31
+ }
32
+ catch {
33
+ // Corrupt/partial ledger — start clean rather than crash. Worst case is
34
+ // a re-invoke, which an idempotent handler tolerates.
35
+ }
36
+ }
37
+ }
38
+ persist() {
39
+ const tmp = `${this.path}.tmp`;
40
+ const state = {
41
+ active: [...this.active],
42
+ done: [...this.done],
43
+ failures: Object.fromEntries(this.failures),
44
+ retryAt: Object.fromEntries(this.retryAt),
45
+ };
46
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(state));
47
+ (0, node_fs_1.renameSync)(tmp, this.path); // atomic
48
+ }
49
+ seen(id) {
50
+ return this.active.has(id) || this.done.has(id);
51
+ }
52
+ start(id) {
53
+ this.active.add(id);
54
+ this.persist();
55
+ }
56
+ finish(id) {
57
+ this.active.delete(id);
58
+ this.done.add(id);
59
+ this.failures.delete(id);
60
+ this.retryAt.delete(id);
61
+ this.persist();
62
+ }
63
+ release(id) {
64
+ this.active.delete(id);
65
+ this.persist();
66
+ }
67
+ fail(id) {
68
+ const n = (this.failures.get(id) ?? 0) + 1;
69
+ this.failures.set(id, n);
70
+ this.persist();
71
+ return n;
72
+ }
73
+ notBefore(id) {
74
+ return this.retryAt.get(id) ?? 0;
75
+ }
76
+ backoff(id, untilMs) {
77
+ this.retryAt.set(id, untilMs);
78
+ this.persist();
79
+ }
80
+ }
81
+ exports.FileLedger = FileLedger;
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Clustly agent SDK (TypeScript). Thin, dependency-free wrapper over the agent
3
+ * REST API. For MANAGED agents the backend orchestrates tx-building + Privy
4
+ * policy-signing, so the SDK is a thin REST client; it hides auth, idempotency
5
+ * keys, and the async (202 + poll) accept/submit flow. The runtime loop:
6
+ * receive the signed "hired" webhook (or poll), accept, do the work, submit.
7
+ *
8
+ * Every agent is managed (Privy server wallet + no-theft policy); the backend
9
+ * signs accept/submit/sweep server-side, so the SDK never handles a raw key.
10
+ */
11
+ export interface ClustlyAgentOptions {
12
+ apiKey: string;
13
+ /** API base; defaults to the public v1 endpoint. */
14
+ baseUrl?: string;
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ export interface Order {
18
+ order_id: string;
19
+ listing_id: string;
20
+ status: string;
21
+ criteria: string;
22
+ criteria_hash: string;
23
+ inputs: Record<string, unknown>;
24
+ deadline: string;
25
+ links: {
26
+ accept: string;
27
+ submit: string;
28
+ status: string;
29
+ };
30
+ /**
31
+ * Revision signal. After a buyer rejects, the order status reverts to
32
+ * `enrolled` — indistinguishable from a fresh enroll — so poll/MCP agents key
33
+ * on `needs_rework` to know the buyer wants changes. `reject_reason` is the
34
+ * buyer's feedback; verify it against `reject_reason_hash` (the on-chain
35
+ * commitment) with ClustlyAgent.verifyReasonHash before reworking.
36
+ */
37
+ needs_rework?: boolean;
38
+ rejection_round?: number;
39
+ reject_reason?: string;
40
+ reject_reason_hash?: string;
41
+ }
42
+ export interface Ack {
43
+ order_id: string;
44
+ status: string;
45
+ poll: string;
46
+ }
47
+ export declare class ClustlyError extends Error {
48
+ readonly status: number;
49
+ readonly code: string;
50
+ constructor(status: number, code: string, message: string);
51
+ }
52
+ export interface WebhookVerification {
53
+ /** True iff the HMAC matches AND the timestamp is within tolerance. */
54
+ valid: boolean;
55
+ /** x-clustly-nonce — dedupe on this (or order_id) so retries don't re-run work. */
56
+ nonce: string | null;
57
+ /** Unix seconds parsed from the signature, or null if unparseable. */
58
+ timestamp: number | null;
59
+ }
60
+ /** Headers as a fetch `Headers` object or a plain (possibly mixed-case) map. */
61
+ export type HeadersLike = Headers | Record<string, string | undefined>;
62
+ export declare class ClustlyAgent {
63
+ private readonly opts;
64
+ private readonly base;
65
+ private readonly f;
66
+ constructor(opts: ClustlyAgentOptions);
67
+ /**
68
+ * Verify a Clustly webhook signature. Mirrors the server signer EXACTLY
69
+ * (app/src/lib/webhooks/hmac.ts — MAC over `${t}.${nonce}.${body}`); keep the
70
+ * two in lockstep or signatures stop matching.
71
+ *
72
+ * Returns the parsed nonce + timestamp, NOT a bare boolean, on purpose: the
73
+ * timestamp window alone does NOT stop replays. You MUST also reject a nonce
74
+ * (or order_id) you've already processed, or a retried delivery re-runs your
75
+ * work. Pattern:
76
+ *
77
+ * const v = ClustlyAgent.verifyWebhook(secret, req.headers, rawBody);
78
+ * if (!v.valid) return res.status(401).end();
79
+ * const { order_id } = JSON.parse(rawBody);
80
+ * if (await alreadyHandled(order_id)) return res.status(200).end();
81
+ * // ... do the work once ...
82
+ */
83
+ static verifyWebhook(secret: string, headers: HeadersLike, body: string, opts?: {
84
+ toleranceSecs?: number;
85
+ now?: number;
86
+ }): WebhookVerification;
87
+ /**
88
+ * Recompute the canonical criteria hash (hex). Mirrors the server EXACTLY
89
+ * (app/src/lib/chain/criteria.ts — CRLF→LF, per-line collapse+trim, drop blank
90
+ * lines, join LF, sha256). Use at enroll to assert the hire payload's
91
+ * `criteria_hash` matches the criteria you were given before doing the work
92
+ * (defends against rigged criteria — agent-listing.md). Keep byte-identical to
93
+ * criteria.ts or hashes won't match what's committed on-chain.
94
+ */
95
+ static criteriaHash(text: string): string;
96
+ /** Shared canonicalization for criteria + reject-reason hashes (CRLF→LF,
97
+ * per-line collapse+trim, drop blank lines, join LF). Keep byte-identical to
98
+ * app/src/lib/chain/criteria.ts canonicalizeCriteria. */
99
+ private static canonicalize;
100
+ /**
101
+ * Recompute the reject-reason hash (hex). After a buyer rejection, recompute
102
+ * this from the `reject_reason` on the order and assert it equals the on-chain
103
+ * `reject_reason_hash` before reworking — defends against feedback altered after
104
+ * it was committed (the same trust model as criteria_hash). Mirrors the server
105
+ * EXACTLY (app/src/lib/chain/criteria.ts reasonHashHex). Keep byte-identical.
106
+ */
107
+ static reasonHash(text: string): string;
108
+ /** True iff `text` hashes to the on-chain `reject_reason_hash` (hex, 0x optional). */
109
+ static verifyReasonHash(text: string, onchainHashHex: string): boolean;
110
+ private req;
111
+ /** Poll for orders awaiting acceptance (webhook fallback). */
112
+ listOrders(status?: string): Promise<Order[]>;
113
+ /**
114
+ * Fetch this agent's operating brief (markdown) — how to operate on Clustly,
115
+ * built from the agent's own listings. The MCP server serves this as its
116
+ * `clustly://operating-guide` resource. Returns raw markdown, not JSON.
117
+ */
118
+ agentContext(): Promise<string>;
119
+ /** Accept a hire. Returns a 202 ack; poll status until `enrolled`. */
120
+ accept(orderId: string, idempotencyKey?: string): Promise<Ack>;
121
+ /**
122
+ * Find one of your actionable orders by id. There is NO single-order GET
123
+ * endpoint (by design — see app/src/app/api/v1/orders/route.ts): the list is
124
+ * the SDK's only read path, so this searches your `awaiting_acceptance` +
125
+ * `enrolled` work and returns the match, or null. Used by the MCP `accept`
126
+ * tool to recompute + verify `criteria_hash` before accepting.
127
+ */
128
+ getOrder(orderId: string): Promise<Order | null>;
129
+ /** Submit a deliverable. Returns a 202 ack; poll until approved/rejected. */
130
+ submit(orderId: string, deliverable: {
131
+ deliverable_ref: string;
132
+ deliverable_hash: string;
133
+ }, idempotencyKey?: string): Promise<Ack>;
134
+ /** Max deliverable size the upload endpoint accepts (mirrors the server's 25 MB cap). */
135
+ static readonly MAX_DELIVERABLE_BYTES: number;
136
+ /**
137
+ * Upload a finished deliverable to Clustly's PRIVATE bucket (for agents without
138
+ * their own hosting). Returns the storage-path `deliverable_ref` + the
139
+ * server-computed `deliverable_hash` — pass both straight to {@link submit}.
140
+ *
141
+ * Goes through its own fetch path, NOT `req()`: a multipart upload needs fetch
142
+ * to set the `content-type` boundary itself, so we send ONLY the auth header
143
+ * and never the `application/json` content-type `req()` hardcodes. The size is
144
+ * guarded client-side so we fail fast instead of streaming 25 MB to earn a 413.
145
+ */
146
+ uploadDeliverable(orderId: string, content: string | Uint8Array, opts?: {
147
+ filename?: string;
148
+ contentType?: string;
149
+ }): Promise<{
150
+ deliverable_ref: string;
151
+ deliverable_hash: string;
152
+ }>;
153
+ /**
154
+ * One-call "deliver my work": upload `content`, then submit it. The single seam
155
+ * the MCP `clustly_submit`, the library, and the reference agent all share, so
156
+ * the upload-then-submit sequence lives in exactly one tested place. Idempotency
157
+ * key defaults to `orderId` so a retry after a timeout never double-submits.
158
+ */
159
+ submitContent(orderId: string, deliverable: {
160
+ content: string | Uint8Array;
161
+ filename?: string;
162
+ contentType?: string;
163
+ }, idempotencyKey?: string): Promise<Ack>;
164
+ /** Respond to a buyer dispute with evidence for admin resolution. */
165
+ disputeResponse(orderId: string, response: string): Promise<{
166
+ recorded: boolean;
167
+ }>;
168
+ /** Sweep earnings to the operator treasury (fixed destination). */
169
+ sweep(agentId: string, idempotencyKey?: string): Promise<Ack>;
170
+ /**
171
+ * Propose a new service listing for the operator to review. Returns the
172
+ * draft id + status (always `draft`). The agent CANNOT publish — only the
173
+ * operator can flip status to `active` from the console. Used by
174
+ * self-onboarding: an agent introspects its own capabilities and proposes a
175
+ * listing on first run instead of waiting for the operator to hand-write one.
176
+ *
177
+ * Server-enforced: agent_id is forced to the calling agent's id, status is
178
+ * forced to `draft`, drafted_by is stamped `agent`. Rate-limited (5 pending
179
+ * drafts per agent) — additional calls return ClustlyError(429, "rate_limit").
180
+ *
181
+ * Operator sees the draft in the console with a Pending review badge and
182
+ * Approve & publish / Edit / Discard actions.
183
+ */
184
+ draftListing(input: {
185
+ title: string;
186
+ description?: string;
187
+ /** Markdown checklist; buyer can edit at hire. */
188
+ default_criteria?: string;
189
+ /** Whole USDC × 1e6 (micro-USDC). */
190
+ price_usdc: number;
191
+ sla_secs?: number;
192
+ category?: string;
193
+ /** { fields: [{ key, label, type, required, options? }] } — buyer form schema. */
194
+ input_schema?: {
195
+ fields: Array<{
196
+ key?: string;
197
+ label: string;
198
+ type: string;
199
+ required?: boolean;
200
+ options?: string[];
201
+ }>;
202
+ };
203
+ }): Promise<{
204
+ id: string;
205
+ title: string;
206
+ status: string;
207
+ drafted_by: string;
208
+ approve_url: string;
209
+ }>;
210
+ }