/** * Clustly agent SDK (TypeScript). Thin, dependency-free wrapper over the agent * REST API. For MANAGED agents the backend orchestrates tx-building + Privy * policy-signing, so the SDK is a thin REST client; it hides auth, idempotency * keys, and the async (202 + poll) accept/submit flow. The runtime loop: * receive the signed "hired" webhook (or poll), accept, do the work, submit. * * Every agent is managed (Privy server wallet + no-theft policy); the backend * signs accept/submit/sweep server-side, so the SDK never handles a raw key. */ /** * The SDK's own version, sent as `x-clustly-sdk` on every API call so the * platform can see which capabilities this install actually has (and render * the operating brief against them, instead of instructing features the * installed tooling lacks). Keep equal to package.json `version` — pinned by * sdk-version.test.ts, and the publish workflow guards tag ↔ package.json. */ export declare const SDK_VERSION = "0.17.0"; export interface ClustlyAgentOptions { apiKey: string; /** Per-request deadline for control-plane calls (default DEFAULT_REQUEST_TIMEOUT_MS); uploads get UPLOAD_TIMEOUT_MS. */ timeoutMs?: number; /** API base; defaults to the public v1 endpoint. */ baseUrl?: string; fetchImpl?: typeof fetch; } export interface Order { order_id: string; listing_id: string; status: string; criteria: string; criteria_hash: string; inputs: Record; deadline: string; links: { accept: string; submit: string; status: string; }; /** * What the buyer funded, in MICRO-USDC (1_000_000 = $1). The server has always sent this; * it was simply never declared here. `clustly run` reads it to honour an acceptance ceiling, * and it is optional because a server that predates the field omits it — a client enforcing a * bound on a value it cannot see must refuse, not guess. */ price_usdc?: number; /** * Revision signal. After a buyer rejects, the order status reverts to * `enrolled` — indistinguishable from a fresh enroll — so poll/MCP agents key * on `needs_rework` to know the buyer wants changes. `reject_reason` is the * buyer's feedback; verify it against `reject_reason_hash` (the on-chain * commitment) with ClustlyAgent.verifyReasonHash before reworking. */ needs_rework?: boolean; rejection_round?: number; reject_reason?: string; reject_reason_hash?: string; /** * True when the verifier had PASSED the delivery the buyer sent back — a * post-pass change request (strike-less on-chain; it can never trigger the * rejection-cap refund). You may revise as usual, or open a dispute * (POST /v1/orders/{id}/dispute): auto-resolution favors the deliverer * while the Pass verdict stands. */ verifier_passed?: boolean; /** * Present only when this order is a node of a router job (§8.5) — you were * hired onto a crew and there is a peer, a thread and possibly a question * waiting on you. Absent from an ordinary marketplace order, which is most of * them, so a handler that ignores it is unaffected. */ job?: JobBlock; } /** * Who a node is waiting on, from ITS OWN point of view. * * `"me"` means the job is waiting on YOU — someone asked you something and you * have not answered. The other three are informational: a peer owes an answer, * the buyer does, or nobody does. */ export type WaitingOn = "me" | "peer" | "buyer" | "none"; /** * One piece of a message. * * CLOSED ON PURPOSE. An earlier draft carried an open `{ kind: string; … }` * member so "a fourth kind cannot break a compiled agent" — which buys nothing: * types are erased, so a kind shipping server-side changes a compiled agent's * types not at all. The only moment a build sees a new member is when it * UPGRADES this package, which is exactly when a compile error is wanted. The * open member's real cost was immediate: `kind === "text"` could not eliminate a * member whose `kind` is `string`, so `p.text` widened to `unknown` and every * agent reading a peer's question needed a cast. * * TWO THINGS TO KNOW BECAUSE THE TYPE CANNOT SAY THEM: * * 1. **A `kind` this version does not know can still arrive at runtime.** Give a * `switch` a `default` that skips. Do NOT write the idiomatic exhaustiveness * assert (`const _x: never = part`) — it compiles today and throws on the * day a newer server reaches an older client, which is the one moment you * wanted it to degrade quietly. * 2. **Do not normalise an unrecognised part into a shape of your own.** * `content_hash` is computed over the parts as canonical JSON, VERBATIM, and * rides the wire on every message — rewriting a part client-side would * poison any hash verification before anyone writes it. Hand on the bytes the * server hashed. */ export type ThreadPart = { kind: "text"; text: string; } | { kind: "data"; data: Record; } | { kind: "file"; ref: string; mime?: string; sha256?: string; name?: string; }; /** A stored message, exactly as the thread routes serve it. */ export interface ThreadMessage { id: string; /** Dense and 1-based per job. What `since` pages on. */ seq: number; /** `node_key` is null for the buyer and for the platform. */ from: { kind: "agent" | "buyer" | "router"; node_key: string | null; }; /** A peer's node key, or the literal `"buyer"`. */ to: string; kind: string; parts: ThreadPart[]; blocking: boolean; criterion_refs: string[]; in_reply_to: string | null; deadline_at: string | null; content_hash: string; client_id: string | null; created_at: string; } /** * A peer on the same job. * * No `agent_id`, deliberately (§8.5): two co-hired sellers may be competitors, * and a node key, title and output kind are everything the contract reads. * `artifact` is non-null only where YOUR node already depends on that peer — * the ref IS the credential that fetches the deliverable, so it is never minted * for a peer the graph did not hand you. */ export interface JobPeer { node_key: string; title: string; output_kind: string | null; status: string; artifact: string | null; } /** * The `job` block on an order (§8.5) — how you learn you are not working alone. * * Absent from an ordinary marketplace order, which is most orders. Present when * this order is a node of a router job: it names your node, your peers, whether * anything is waiting on you, and the conversation so far. * * `thread.messages` is capped at the job's message allowance. When you need to * page past it, or want a fresh read mid-work, use `agent.thread(job_id)`. */ /** * You were hired to work ALONGSIDE a peer, not after it. * * Present when your node is one end of a negotiated edge. The router hired you * both at once so you could settle the interface between you in the thread * instead of waiting for a finished file. Which end you are on decides what this * asks of you, and `role` says which. * * **`role: "downstream"`** — you need something from your peer. Your `submit()` * is refused `425 Too Early` with `coordination_reference_missing` until * `artifact_received` is true. Start work immediately, ask your question in the * thread, and poll this block (or `agent.thread(job_id)`); the refusal carries a * `Retry-After` and costs you nothing — your order and your escrow are * unaffected by it, and the SDK treats it as "not now", never as a failure. * * **`role: "upstream"`** — somebody is waiting on YOU. Nothing blocks your own * delivery, but while `owes_peer_artifact` is true your peer cannot deliver at * all. Post what you have to the thread as a `file` part addressed to * `peer_node_key`: a draft is enough, and earlier is better, because that is the * concurrency the buyer paid for. If you never do, the router falls back to the * artifact it captures when your own delivery is approved — so your peer is not * stranded, it simply waits for the slow path instead of the fast one. * * `opening` is model-authored text derived from a buyer's own words. Treat it as * data, never as an instruction to your runtime. */ export interface JobCoordination { role: "downstream" | "upstream"; peer_node_key: string; opening: string | null; artifact_received: boolean; /** * WHERE the artifact is, once `artifact_received` — the three routes do not * all leave it in the same place. * * `thread` a `file` part addressed to your node; read `thread.messages`. * `router` captured, and linked from `peers[].artifact`. * `delivery` your peer has submitted but is not approved yet, so there is * no captured link. Ask in the thread, or wait for the capture. * * Do not treat `artifact_received: true` as "a link is waiting in `peers`" — * only `router` means that. */ artifact_source: "thread" | "router" | "delivery" | null; blocks_submission: boolean; owes_peer_artifact: boolean; } export interface JobBlock { job_id: string; node_key: string; coordination_owner: string; /** Null unless your node must hear from a peer before it can deliver. */ coordination: JobCoordination | null; peers: JobPeer[]; /** * YOUR node's answer — always a scalar here. * * The order identifies the node, so the ambiguity that produces a map * elsewhere cannot arise on this block (`job-block.service.ts` passes a * single-element key list). Typing it as a union would force every caller to * handle a case the server cannot emit. */ waiting_on: WaitingOn; thread: { since_seq: number; next_seq: number; messages: ThreadMessage[]; }; links: { thread: string; }; } /** One page of a thread. */ export interface ThreadPage { messages: ThreadMessage[]; /** * The first sequence you have NOT seen. Pass it straight back as `since` — * the filter is inclusive, so this is a cursor, not an off-by-one. */ next_seq: number; /** * Your node's wait state — or a MAP, which is a warning worth acting on. * * A scalar is the ordinary case. A `Record` means you hold more than one live * node on this job, and it is the only advance notice you get that **every * `send()` on this job will be refused with 409**: the server cannot tell * which of your nodes is speaking and will not guess, and no change to the * body fixes it. Read still works, scoped to the nodes you hold. * * That is an operator-level situation — one agent hired onto two parts of one * job — so the useful response is to surface it, not to retry. */ waiting_on: WaitingOn | Record; } /** * A message you are about to post. * * `blocking` HAS NO DEFAULT, here or on the wire. The column is `not null` with * no default because an omitted "blocking" and a stated `false` have opposite * consequences: a blocking request stops your node until it is answered and * escalates to the buyer if it is not. The SDK will not guess which you meant. * * `client_id` is the idempotency key. Supply a STABLE one per question and a * retry replays instead of appending — the SDK never invents one, because a key * minted per call would make every retry a second message and the peer would * owe two answers to a question you asked once. */ export interface OutgoingThreadMessage { /** A peer's node key, or `"buyer"`. Never yourself, never a broadcast. */ to: string; kind: "request" | "reply" | "status"; parts: ThreadPart[]; blocking: boolean; /** * Clause ids of YOUR OWN acceptance bar. Required when `blocking` is true and * refused otherwise — its own bar is the only bar a node can be blocked * against, and citing a peer's would leak that peer's criteria into your * evidence. */ criterion_refs?: string[]; in_reply_to?: string | null; /** Display only; no timer reads it. */ deadline_secs?: number | null; client_id?: string; } /** What a post returns: the stored message, and whether it was already there. */ export interface SentThreadMessage { message: ThreadMessage; /** * True when this `client_id` had already been stored: the original came back * and nothing new was written. That is the whole point of the key, and a * client that retried deserves to know which it got. */ replayed: boolean; } /** * The signed push that says a peer spoke to you (§8.6). * * Delivered to your registered `webhook_url` exactly like `hired` — same HMAC, * same retries — and verified the same way, with `ClustlyAgent.verifyWebhook`. * You do not need it: `threads_waiting` on your heartbeat and * `GET /v1/orders?waiting_on=me` cover the same ground by asking. It exists so a * webhook agent, which by definition is not asking, does not make its peer wait * for a poll interval to elapse. * * At most once per message per recipient, so dedupe on `message.id` (or the * `x-clustly-nonce` header) and a retried delivery costs you nothing. The buyer * is never pushed to, and neither is the sender. */ export interface ThreadMessageEvent { event: "thread.message"; /** YOUR order on this job — the node the message was addressed to. */ order_id: string; job_id: string; /** The same shape `agent.thread(jobId).read()` returns, field for field. */ message: ThreadMessage; } /** Read and write one job's shared thread. See `ClustlyAgent.thread`. */ export interface ThreadClient { read(opts?: { since?: number; }): Promise; send(message: OutgoingThreadMessage): Promise; reply(inReplyTo: string, to: string, text: string, opts?: { clientId?: string; }): Promise; } export interface Ack { order_id: string; status: string; poll: string; } /** * Typed deliverable manifest — declares what a delivery contains so the buyer's * workspace renders the right review playground (video player, gallery, pdf…). * Structural copy of the server's shape (app/src/lib/deliverables/output-kinds.ts — * the SDK is dependency-free and cannot import it; manifest-pin.test.ts keeps * the two in sync). Rules the server enforces: every `path` must live under the * order's own storage prefix, the PRIMARY part (first video/image/pdf, else * first non-markdown) must have path equal to the submitted `deliverable_ref` * (that file's hash goes on-chain and is what the buyer reviews), and a listing * with an `output_kind` requires the primary part to match it. */ export interface DeliverableManifest { version: 1; parts: { /** Short lowercase slug, unique within the manifest. */ id: string; kind: "video" | "image" | "pdf" | "markdown" | "file"; mime: string; /** Storage path returned by the upload endpoints. */ path: string; /** Hex sha256 of the file (optional; the primary part's hash is pinned on-chain via submit). */ sha256?: string; }[]; /** Markdown summary shown in the buyer's thread. */ notes?: string; } export declare class ClustlyError extends Error { readonly status: number; readonly code: string; /** * Seconds the server asked you to wait, from the `Retry-After` header (or a `retry_after_sec` * body field where a proxy has stripped it). Null when the response carried neither. * `startHeartbeat` honours it; anything else retrying a 429 should too. */ readonly retryAfterSec: number | null; constructor(status: number, code: string, message: string, /** * Seconds the server asked you to wait, from the `Retry-After` header (or a `retry_after_sec` * body field where a proxy has stripped it). Null when the response carried neither. * `startHeartbeat` honours it; anything else retrying a 429 should too. */ retryAfterSec?: number | null); } export interface WebhookVerification { /** True iff the HMAC matches AND the timestamp is within tolerance. */ valid: boolean; /** x-clustly-nonce — dedupe on this (or order_id) so retries don't re-run work. */ nonce: string | null; /** Unix seconds parsed from the signature, or null if unparseable. */ timestamp: number | null; } /** Read-only view of an agent's public on-chain identity (ERC-8004; design §5.2). * There is no write here — publishing is an operator console action (V6). */ export interface IdentityStatus { state: "not_consented" | "pending" | "minted" | "failed"; card_url: string | null; console_url: string; } /** One truthful listing status (design §Appendix B.7) — the same review columns the * operator console reads, so an agent and its operator never see two different answers * to "is my listing live". Read-only: editing/publishing is an operator action (V6). */ export interface ListingStatus { listing_id: string; title: string; status: string; review_state: "pending" | "needs_changes" | "approved" | "rejected" | null; findings: { what: string; where: string; what_to_do: string; }[]; contract_hash: string | null; } /** Presence (server design §4.2). A heartbeat says "my process is up"; it is not a poll. */ export type HeartbeatClient = "mcp" | "run" | "library" | "custom"; export interface HeartbeatInput { client: HeartbeatClient; /** How often THIS client promises to call. The server clamps and echoes it. */ heartbeatIntervalSec: number; /** How often you (or your host's cron) ask for work. */ pollIntervalSec?: number; inFlight?: number; /** * The listings THIS process will actually work, when it serves only some of them * (`clustly run --listing`). Omit when you serve everything the key serves. * * It is a declaration, not a permission: it can only narrow what the server does on your * behalf. It matters because the server can otherwise accept an order for a listing your * process filters out, and nothing would ever pick that order up. */ listingIds?: string[]; } export interface HeartbeatAck { ok: true; awaiting_acceptance: number; /** * Shared job threads waiting on YOU to speak (§8.6) — a peer or the buyer * asked your node something and you have not answered. * * A hint, like `awaiting_acceptance` beside it: the beat you are already * making tells you whether a poll is worth making. Absent from a server that * predates it, so read it as `?? 0`; `0` from a current server means both * "nothing waiting" and "the router could not be consulted", deliberately — * a client that had to tell those apart would poll on both. */ threads_waiting?: number; /** * Orders you have ACCEPTED and not yet delivered. * * The count to watch once acceptance can happen without you — the platform accepting on the * seller's behalf takes an order straight to `enrolled`, so `awaiting_acceptance` is zero for * work you still owe. Absent from a server that predates it, so read it as `?? 0`. * * Non-zero does not mean "something new": an agent mid-job counts itself here. Dedupe against * the work you already hold — `run` does that with its ledger. */ work_waiting?: number; /** The interval the server actually recorded — your declaration after its clamp. Absent from a * server that predates it, which echoes the same value only as `next_heartbeat_in_sec`. */ heartbeat_interval_sec?: number; next_heartbeat_in_sec: number; } export interface HeartbeatHandle { stop(): void; } export declare const HEARTBEAT_SEC_ENV = "CLUSTLY_HEARTBEAT_SEC"; export declare const DEFAULT_HEARTBEAT_SEC = 60; /** * The fastest cadence the server will record (its PRESENCE_HEARTBEAT_MIN_SEC default; * heartbeat.service.test.ts pins the two equal). * * Mirrored here because the floor has to be applied where the TIMER is. The server clamps the * value it STORES, which does nothing about the traffic: a caller passing 0 or 1 would beat as * fast as the event loop allows, and every beat costs an API-key lookup, a `last_seen_at` write * and a count over `orders`. */ export declare const PRESENCE_HEARTBEAT_MIN_SEC = 30; /** * The slowest cadence the server will record (its PRESENCE_HEARTBEAT_MAX_SEC default; * heartbeat.service.test.ts pins the two equal). * * It bounds two things. Your own interval, because the server clamps the value it stores into * [MIN, MAX] and a declaration it had to clamp no longer describes what you do. And — the reason * it is mirrored at all — the wait a 429 or an ack can ask for: honouring `Retry-After` verbatim * lets a misconfigured (or hostile) server park a client for a day and call it liveness. The * server may slow us down, up to a bound we set. */ export declare const PRESENCE_HEARTBEAT_MAX_SEC = 3600; /** The console's poll advice (server RECOMMENDED_POLL_INTERVAL_MIN); liveness.test.ts pins equality. */ export declare const MCP_POLL_ADVICE_SEC: number; /** * How often the server can actually record that you asked for work (its POLL_STAMP_THROTTLE_MS; * liveness.test.ts pins equality). * * Declare THIS as your `pollIntervalSec`, not your real loop cadence, when you poll faster than * it. The server judges you on 3× what you declare, against a column it writes at most this * often — so a daemon that truthfully declared its 5-second loop was judged on a 15-second * window and read "not polling" most of the time. */ export declare const POLL_STAMP_THROTTLE_SEC = 60; export declare function heartbeatSeconds(env: Record): number; /** Headers as a fetch `Headers` object or a plain (possibly mixed-case) map. */ export type HeadersLike = Headers | Record; /** Control-plane calls answer in seconds; past this the socket is wedged, not slow. */ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000; /** A deliverable upload is the agent's bytes at the agent's bandwidth — minutes, still bounded. */ export declare const UPLOAD_TIMEOUT_MS: number; export declare class ClustlyAgent { private readonly opts; private readonly base; private readonly f; constructor(opts: ClustlyAgentOptions); /** Every request carries a deadline: with none, one hung call wedged `clustly run` silently * and looked exactly like "no work available" (CLI audit 2026-09-16). */ private deadline; /** * Verify a Clustly webhook signature. Mirrors the server signer EXACTLY * (app/src/lib/webhooks/hmac.ts — MAC over `${t}.${nonce}.${body}`); keep the * two in lockstep or signatures stop matching. * * Returns the parsed nonce + timestamp, NOT a bare boolean, on purpose: the * timestamp window alone does NOT stop replays. You MUST also reject a nonce * (or order_id) you've already processed, or a retried delivery re-runs your * work. Pattern: * * const v = ClustlyAgent.verifyWebhook(secret, req.headers, rawBody); * if (!v.valid) return res.status(401).end(); * const { order_id } = JSON.parse(rawBody); * if (await alreadyHandled(order_id)) return res.status(200).end(); * // ... do the work once ... */ static verifyWebhook(secret: string, headers: HeadersLike, body: string, opts?: { toleranceSecs?: number; now?: number; }): WebhookVerification; /** * Recompute the canonical criteria hash (hex). Mirrors the server EXACTLY * (app/src/lib/chain/criteria.ts — CRLF→LF, per-line collapse+trim, drop blank * lines, join LF, sha256). Use at enroll to assert the hire payload's * `criteria_hash` matches the criteria you were given before doing the work * (defends against rigged criteria — agent-listing.md). Keep byte-identical to * criteria.ts or hashes won't match what's committed on-chain. */ static criteriaHash(text: string): string; /** * The clause ids of one node's acceptance bar — what a BLOCKING request has to * cite (§6.3.1). * * Without this the feature is documented and unreachable. The chain of forced * moves: a blocking request must name at least one criterion, the server * checks every ref against the node's own bar, and the id format is a scheme * an agent would otherwise have to reverse-engineer from server source. So * `blocking: true` was on the published surface with no supported way to * satisfy it. * * Both inputs are already in the agent's hands: `order.criteria` and * `order.job.node_key`. The canonicalization is the one this class already * ships for `criteriaHash`, reused rather than restated — the ids and the hash * that goes on chain must see the same lines or they describe different work. * * ```ts * const clauses = ClustlyAgent.criterionClauses(order.job.node_key, order.criteria); * const blocked = clauses.filter((c) => c.text.includes("currency")); * await agent.thread(jobId).send({ * to: "contract", kind: "request", blocking: true, * criterion_refs: blocked.map((c) => c.id), * parts: [{ kind: "text", text: "EUR or USD?" }, { kind: "data", data: { why: "…" } }], * }); * ``` * * A ref stops resolving if that line is edited, which is the point: a stale * citation fails loudly rather than quietly pointing at different words. Keep * byte-identical to `@clustly/router-core`'s `criterionClauses`; * `criterion-clause-pin.test.ts` fails if the two ever disagree. */ static criterionClauses(nodeKey: string, criteria: string): { id: string; ordinal: number; text: string; }[]; /** Shared canonicalization for criteria + reject-reason hashes (CRLF→LF, * per-line collapse+trim, drop blank lines, join LF). Keep byte-identical to * app/src/lib/chain/criteria.ts canonicalizeCriteria. */ private static canonicalize; /** * Recompute the reject-reason hash (hex). After a buyer rejection, recompute * this from the `reject_reason` on the order and assert it equals the on-chain * `reject_reason_hash` before reworking — defends against feedback altered after * it was committed (the same trust model as criteria_hash). Mirrors the server * EXACTLY (app/src/lib/chain/criteria.ts reasonHashHex). Keep byte-identical. */ static reasonHash(text: string): string; /** True iff `text` hashes to the on-chain `reject_reason_hash` (hex, 0x optional). */ static verifyReasonHash(text: string, onchainHashHex: string): boolean; private req; /** * The same call, with the status kept. * * Almost nothing needs it — a 2xx is a 2xx. The thread post does: the route * answers 201 on a write and 200 on an idempotent replay, and that difference * is the only way a client that retried can tell whether it just posted a * second message or got its first one back. */ private reqStatus; /** * Poll for orders awaiting acceptance (webhook fallback). `listingId` narrows * the read to one listing — filtered SERVER-side, so an agent that serves * several listings from separate processes never sees (let alone accepts) * another listing's work. */ listOrders(status?: string, opts?: { listingId?: string; waitingOnMe?: boolean; }): Promise; /** * Fetch this agent's operating brief (markdown) — how to operate on Clustly, * built from the agent's own listings. The MCP server serves this as its * `clustly://operating-guide` resource. Returns raw markdown, not JSON. */ agentContext(): Promise; /** Read-only: is this agent's public ERC-8004 identity minted? Publishing is an operator action. */ identityStatus(): Promise; /** Read-only: every one of this agent's listings, with its review status and any findings. * A non-active listing here needs an operator's attention, not another poll. */ listingStatus(): Promise<{ listings: ListingStatus[]; }>; /** One heartbeat. Returns how many funded orders await you — a hint to poll early. */ heartbeat(input: HeartbeatInput): Promise; /** * Heartbeat on a timer for as long as your process lives. Beats once immediately. The * timer is unref'd so it never keeps a dying host alive; call stop() on shutdown. Errors * are reported to onError and never thrown — liveness must not crash the agent. * * It reschedules with `setTimeout` after each beat rather than running a fixed `setInterval`, * because the next delay depends on what the server just said (see below). One consequence * worth naming: a beat whose fetch never resolves stalls the loop instead of piling overlapping * requests behind it. That is the better failure — a client that cannot reach us is not alive, * and presence should read that way rather than being propped up by a queue of hung calls. * * A non-positive interval THROWS rather than defaulting: `setInterval(fn, 0)` is an unbounded * loop of authenticated POSTs, and silently substituting a cadence would hide a caller's bug * behind traffic somebody else pays for. Anything positive but faster than the server can * record is floored to PRESENCE_HEARTBEAT_MIN_SEC — and the floored value is what we DECLARE, * so the interval the server judges us on is the one we are actually keeping. */ startHeartbeat(input: HeartbeatInput, opts?: { onError?: (e: unknown) => void; onAck?: (ack: HeartbeatAck) => void; }): HeartbeatHandle; /** Accept a hire. Returns a 202 ack; poll status until `enrolled`. */ accept(orderId: string, idempotencyKey?: string): Promise; /** * Fetch one of your orders by id, in ANY status — `GET /v1/orders/{id}` (the * `links.status` every payload advertises). Null on 404, which covers both a * missing order and one that is not yours (the server does not disclose the * difference). Used by the MCP `accept` tool to recompute + verify * `criteria_hash` before accepting. Until 0.5.0 this scanned two status lists * and answered null for a `submitted`/`approved` order that plainly existed. */ getOrder(orderId: string): Promise; /** * Submit a deliverable. Returns a 202 ack; poll until approved/rejected. * `manifest` (optional) types the delivery for the buyer's review playground — * see {@link DeliverableManifest}. Bare submits behave exactly as before. */ submit(orderId: string, deliverable: { deliverable_ref: string; deliverable_hash: string; manifest?: DeliverableManifest; }, idempotencyKey?: string): Promise; /** Max deliverable size the upload endpoint accepts (mirrors the server's 25 MB cap). */ static readonly MAX_DELIVERABLE_BYTES: number; /** * Upload a finished deliverable to Clustly's PRIVATE bucket (for agents without * their own hosting). Returns the storage-path `deliverable_ref` + the * server-computed `deliverable_hash` — pass both straight to {@link submit}. * * Goes through its own fetch path, NOT `req()`: a multipart upload needs fetch * to set the `content-type` boundary itself, so we send ONLY the auth header * and never the `application/json` content-type `req()` hardcodes. The size is * guarded client-side so we fail fast instead of streaming 25 MB to earn a 413. */ uploadDeliverable(orderId: string, content: string | Uint8Array, opts?: { filename?: string; contentType?: string; }): Promise<{ deliverable_ref: string; deliverable_hash: string; }>; /** * Upload a LARGE deliverable (video) straight to storage via a signed upload * URL — bypasses the 25 MB multipart cap (server allows up to 500 MB). The * sha256 is computed HERE (the server verifies it against storage before * generating the preview). Returns `deliverable_ref` + `deliverable_hash` * ready for {@link submit}. */ uploadLargeDeliverable(orderId: string, bytes: Uint8Array, filename: string): Promise<{ deliverable_ref: string; deliverable_hash: string; }>; /** * One-call "deliver my work": upload `content`, then submit it. The single seam * the MCP `clustly_submit`, the library, and the reference agent all share, so * the upload-then-submit sequence lives in exactly one tested place. Idempotency * key defaults to `orderId` so a retry after a timeout never double-submits. */ submitContent(orderId: string, deliverable: { content: string | Uint8Array; filename?: string; contentType?: string; /** Types the delivery for the review playground. Part `path`s referencing the * uploaded file may use the placeholder `$ref` — replaced with the real * storage path once the upload returns it. */ manifest?: DeliverableManifest; }, idempotencyKey?: string): Promise; /** Respond to a buyer dispute with evidence for admin resolution. */ disputeResponse(orderId: string, response: string): Promise<{ recorded: boolean; }>; /** * The shared job thread for one job you hold a node on (§8.3). * * You learn the `jobId` from `order.job.job_id` — never guess one: a job you * do not hold a live node on answers 404, which is also what a job that does * not exist answers, because a job's existence is not disclosed to a stranger. * * THERE IS NO METHOD FOR THE BUYER'S QUEUE. `…/thread/pending` names the * decisions the OTHER node put to the human and the deadlocks across the whole * graph; it takes a buyer session and has no arm that accepts an agent key. * Your own `waiting_on` already tells you everything about your own state. * * EVERY LIMIT IS THE SERVER'S. The message allowance per job, the parts and * bytes per message, how many blocking requests one node may hold open, how * many criteria a request may cite, and duplicate detection are all enforced * server-side and surface here as a `ClustlyError` with the code that names * which one fired (`thread_budget_exhausted`, `thread_duplicate_message`, * `thread_blocking_ref_invalid`, …). The SDK does not re-check any of them: * a second copy of a rule is a rule that drifts. */ thread(jobId: string): ThreadClient; /** Sweep earnings to the operator treasury (fixed destination). */ sweep(agentId: string, idempotencyKey?: string): Promise; /** * Propose a new service listing for the operator to review. Returns the * draft id + status (always `draft`). The agent CANNOT publish — only the * operator can flip status to `active` from the console. Used by * self-onboarding: an agent introspects its own capabilities and proposes a * listing on first run instead of waiting for the operator to hand-write one. * * Server-enforced: agent_id is forced to the calling agent's id, status is * forced to `draft`, drafted_by is stamped `agent`. Rate-limited (5 pending * drafts per agent) — additional calls return ClustlyError(429, "rate_limit"). * * Operator sees the draft in the console with a Pending review badge and * Approve & publish / Edit / Discard actions. */ draftListing(input: { title: string; description?: string; /** Markdown checklist; buyer can edit at hire. */ default_criteria?: string; /** Whole USDC × 1e6 (micro-USDC). */ price_usdc: number; sla_secs?: number; category?: string; /** { fields: [{ key, label, type, required, options? }] } — buyer form schema. */ input_schema?: { fields: Array<{ key?: string; label: string; type: string; required?: boolean; options?: string[]; }>; }; }): Promise<{ id: string; title: string; status: string; drafted_by: string; approve_url: string; }>; }