import type { SecretMetadata, SecretWithValue, SecretListResponse, VaultResponse, VaultListResponse, PolicyResponse, ShareLinkResponse, SimulationResponse, BundleSimulationResponse, TransactionResponse, SignTransactionResponse, SigningKeyResponse, SigningKeyListResponse, SignIntentRequest, SignIntentResponse, AgentProfile, ApiErrorBody, } from "./types.js"; export class OneClawApiError extends Error { constructor( public status: number, public detail: string, ) { super(detail); this.name = "OneClawApiError"; } } export interface ClientConfig { baseUrl: string; token: string; vaultId: string; } export interface AgentCredentials { baseUrl: string; agentId?: string; apiKey: string; vaultId?: string; } interface AgentTokenResponse { access_token: string; expires_in: number; agent_id?: string; vault_ids?: string[]; } function encodePath(path: string): string { return path .split("/") .map((s) => encodeURIComponent(s)) .join("/"); } const REFRESH_BUFFER_MS = 60_000; export class OneClawClient { private baseUrl: string; private token: string; private _vaultId: string; private _resolvedAgentId?: string; private agentCredentials?: { agentId?: string; apiKey: string }; private tokenExpiresAt = 0; constructor(config: ClientConfig | AgentCredentials) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); this._vaultId = config.vaultId ?? ""; if ("apiKey" in config && !("token" in config)) { this.agentCredentials = { agentId: config.agentId, apiKey: config.apiKey, }; this.token = ""; } else { this.token = (config as ClientConfig).token; } } /** * Drops the in-memory JWT so the next request re-exchanges the API key. * Automatic retry on 401 / stale-scope 403 also calls this; you can invoke * manually after dashboard policy edits if you need an immediate refresh. */ invalidateCachedAgentToken(): void { if (!this.agentCredentials) return; this.token = ""; this.tokenExpiresAt = 0; } async ensureToken(): Promise { if (!this.agentCredentials) return; if (this.token && Date.now() < this.tokenExpiresAt - REFRESH_BUFFER_MS) return; const body: Record = { api_key: this.agentCredentials.apiKey, }; if (this.agentCredentials.agentId) { body.agent_id = this.agentCredentials.agentId; } const res = await fetch(`${this.baseUrl}/v1/auth/agent-token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { let detail = `HTTP ${res.status}`; try { const errBody = (await res.json()) as ApiErrorBody; if (errBody.detail) detail = errBody.detail; } catch { /* use default */ } throw new OneClawApiError( res.status, `Agent auth failed: ${detail}`, ); } const data = (await res.json()) as AgentTokenResponse; this.token = data.access_token; this.tokenExpiresAt = Date.now() + data.expires_in * 1000; if (data.agent_id) { this._resolvedAgentId = data.agent_id; if (this.agentCredentials && !this.agentCredentials.agentId) { this.agentCredentials.agentId = data.agent_id; } } if (!this._vaultId && data.vault_ids && data.vault_ids.length === 1) { this._vaultId = data.vault_ids[0]; } } private async autoDiscoverVault(): Promise { const vaults = await this.listVaults(); if (!vaults.vaults || vaults.vaults.length === 0) { return; } // Prefer newest vault when several exist (e.g. user just created one); stable UX without ONECLAW_VAULT_ID. const sorted = [...vaults.vaults].sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), ); this._vaultId = sorted[0].id; } /** * Resolve JWT + pick a default vault when none is configured (token claim, then newest list). * Safe to call before status checks so vaultId is populated without requiring env vars. */ async ensureVaultResolved(): Promise { if (this._vaultId) { return; } await this.ensureToken(); await this.autoDiscoverVault(); } private async headers(): Promise> { await this.ensureToken(); return { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json", }; } private async resolveVaultUrl(suffix = ""): Promise { if (!this._vaultId) { await this.autoDiscoverVault(); } if (!this._vaultId) { throw new OneClawApiError( 400, "No vault available yet. Use oneclaw_create_vault, or ensure your org has at least one vault. " + "Optional: set ONECLAW_VAULT_ID only if you need to pin one vault when several exist (not a secret).", ); } return `${this.baseUrl}/v1/vaults/${this._vaultId}${suffix}`; } private shouldReexchangeAgentToken(status: number, detail: string): boolean { if (!this.agentCredentials) return false; if (status === 401) return true; if (status !== 403) return false; const d = detail.toLowerCase(); return ( d.includes("no scopes") || d.includes("scopes do not cover") || d.includes("token has been revoked") ); } private async request( url: string, init?: RequestInit, isRetry = false, ): Promise { const hdrs = await this.headers(); const res = await fetch(url, { ...init, headers: { ...hdrs, ...(init?.headers as Record) }, }); if (!res.ok) { let detail = `HTTP ${res.status}`; let errorType = ""; try { const body = (await res.json()) as ApiErrorBody; if (body.detail) detail = body.detail; if (body.type) errorType = body.type; } catch { // use default detail } if (res.status === 402) { throw new OneClawApiError( 402, "Quota exhausted. Ask your human to upgrade the plan, add prepaid credits, or enable x402 micropayments at https://1claw.xyz/settings/billing", ); } if ( res.status === 403 && errorType === "resource_limit_exceeded" ) { throw new OneClawApiError( 403, `Resource limit reached: ${detail}. Ask your human to upgrade the plan at https://1claw.xyz/settings/billing`, ); } if ( !isRetry && this.shouldReexchangeAgentToken(res.status, detail) ) { this.invalidateCachedAgentToken(); return this.request(url, init, true); } throw new OneClawApiError(res.status, detail); } if (res.status === 204) return undefined as T; return res.json() as Promise; } get agentId(): string | undefined { return this._resolvedAgentId ?? this.agentCredentials?.agentId; } get vaultId(): string { return this._vaultId; } get tokenTtlMs(): number { if (!this.tokenExpiresAt) return 0; return Math.max(0, this.tokenExpiresAt - Date.now()); } get isAuthenticated(): boolean { return !!this.token; } // ── Secrets ────────────────────────────────────────── async listSecrets(): Promise { return this.request( await this.resolveVaultUrl("/secrets"), ); } async getSecret(path: string): Promise { return this.request( await this.resolveVaultUrl(`/secrets/${encodePath(path)}`), ); } async putSecret( path: string, body: { value: string; type: string; metadata?: Record; expires_at?: string; max_access_count?: number; }, ): Promise { return this.request( await this.resolveVaultUrl(`/secrets/${encodePath(path)}`), { method: "PUT", body: JSON.stringify(body) }, ); } async deleteSecret(path: string): Promise { await this.request( await this.resolveVaultUrl(`/secrets/${encodePath(path)}`), { method: "DELETE" }, ); } // ── Vaults ─────────────────────────────────────────── async createVault( name: string, description?: string, ): Promise { const vault = await this.request( `${this.baseUrl}/v1/vaults`, { method: "POST", body: JSON.stringify({ name, description: description ?? "" }), }, ); if (vault?.id) { this._vaultId = vault.id; } return vault; } async listVaults(): Promise { return this.request(`${this.baseUrl}/v1/vaults`); } /** * Create a vault and (best-effort) share it back to the human who registered * this agent (`agents.created_by`). Returns the vault plus either the sharing * policy that was created or a human-readable reason for skipping the share. * Never throws on share failure — the vault is always returned when creation succeeds. */ async createVaultAndShareWithCreator( name: string, description?: string, permissions: string[] = ["read", "write", "admin"], ): Promise<{ vault: VaultResponse; shared_with?: { user_id: string; policy_id: string; permissions: string[] }; reason?: string; }> { const vault = await this.createVault(name, description); let creatorId: string | undefined; try { const profile = await this.getAgentProfile(); creatorId = profile.created_by; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { vault, reason: `Vault created; could not read agent profile to find the human creator (${msg}). Have the human grant themselves access from the dashboard.`, }; } if (!creatorId) { return { vault, reason: "Vault created; no `created_by` on agent profile (agent was enrolled without an email). The human must grant themselves access from the dashboard.", }; } try { const policy = await this.createPolicy( vault.id, "user", creatorId, permissions, "**", ); return { vault, shared_with: { user_id: creatorId, policy_id: policy.id, permissions: policy.permissions, }, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { vault, reason: `Vault created, but failed to share with creator (${msg}). Have the human grant themselves access from the dashboard.`, }; } } // ── Policies ───────────────────────────────────────── async createPolicy( vaultId: string, principalType: string, principalId: string, permissions: string[], secretPathPattern = "**", ): Promise { return this.request( `${this.baseUrl}/v1/vaults/${vaultId}/policies`, { method: "POST", body: JSON.stringify({ secret_path_pattern: secretPathPattern, principal_type: principalType, principal_id: principalId, permissions, }), }, ); } // ── Sharing ────────────────────────────────────────── async shareSecret( secretId: string, options: { recipient_type: string; email?: string; recipient_id?: string; expires_at: string; max_access_count?: number; }, ): Promise { return this.request( `${this.baseUrl}/v1/secrets/${secretId}/share`, { method: "POST", body: JSON.stringify(options) }, ); } // ── Transactions ───────────────────────────────────── async simulateTransaction( agentId: string, tx: { to: string; value: string; chain: string; data?: string; signing_key_path?: string; gas_limit?: number; }, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/transactions/simulate`, { method: "POST", body: JSON.stringify(tx) }, ); } async simulateBundle( agentId: string, transactions: Array<{ to: string; value: string; chain: string; data?: string; signing_key_path?: string; gas_limit?: number; }>, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/transactions/simulate-bundle`, { method: "POST", body: JSON.stringify({ transactions }) }, ); } async submitTransaction( agentId: string, tx: { to: string; value: string; chain: string; data?: string; signing_key_path?: string; nonce?: number; gas_price?: string; gas_limit?: number; max_fee_per_gas?: string; max_priority_fee_per_gas?: string; simulate_first?: boolean; }, idempotencyKey?: string, ): Promise { const key = idempotencyKey ?? crypto.randomUUID(); return this.request( `${this.baseUrl}/v1/agents/${agentId}/transactions`, { method: "POST", body: JSON.stringify(tx), headers: { "Idempotency-Key": key }, }, ); } async signTransaction( agentId: string, tx: { to: string; value: string; chain: string; data?: string; signing_key_path?: string; nonce?: number; gas_price?: string; gas_limit?: number; max_fee_per_gas?: string; max_priority_fee_per_gas?: string; simulate_first?: boolean; }, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/transactions/sign`, { method: "POST", body: JSON.stringify(tx) }, ); } async listTransactions( agentId: string, opts?: { include_signed_tx?: boolean }, ): Promise<{ transactions: TransactionResponse[] }> { const qs = opts?.include_signed_tx ? "?include_signed_tx=true" : ""; return this.request<{ transactions: TransactionResponse[] }>( `${this.baseUrl}/v1/agents/${agentId}/transactions${qs}`, ); } async getTransaction( agentId: string, txId: string, opts?: { include_signed_tx?: boolean }, ): Promise { const qs = opts?.include_signed_tx ? "?include_signed_tx=true" : ""; return this.request( `${this.baseUrl}/v1/agents/${agentId}/transactions/${txId}${qs}`, ); } // ── Signing Keys ───────────────────────────────────── async createSigningKey( agentId: string, chain: string, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/signing-keys`, { method: "POST", body: JSON.stringify({ chain }) }, ); } async listSigningKeys( agentId: string, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/signing-keys`, ); } async rotateSigningKey( agentId: string, chain: string, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/signing-keys/${encodeURIComponent(chain)}/rotate`, { method: "POST" }, ); } async deactivateSigningKey( agentId: string, chain: string, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/signing-keys/${encodeURIComponent(chain)}`, { method: "DELETE" }, ); } // ── Unified Sign ───────────────────────────────────── async sign( agentId: string, params: SignIntentRequest, ): Promise { return this.request( `${this.baseUrl}/v1/agents/${agentId}/sign`, { method: "POST", body: JSON.stringify(params) }, ); } // ── Execution Intents ───────────────────────────────── async executeHttp( agentId: string, params: { binding: string; method?: string; path?: string; headers?: Record; body?: Record; execution_mode?: string; }, ): Promise> { return this.request>( `${this.baseUrl}/v1/agents/${agentId}/execute`, { method: "POST", body: JSON.stringify({ binding: params.binding, intent_type: "http", execution_mode: params.execution_mode ?? "vault", params: { method: params.method ?? "GET", path: params.path ?? "", headers: params.headers, body: params.body, }, }), }, ); } async listBindings( agentId: string, ): Promise<{ bindings: Array> }> { return this.request<{ bindings: Array> }>( `${this.baseUrl}/v1/agents/${agentId}/bindings`, ); } // ── Approvals ───────────────────────────────────────── async requestApproval(data: { action: string; target_type: string; target_id: string; summary: Record; reason?: string; risk_tier?: number; }): Promise<{ id: string; status: string }> { return this.request<{ id: string; status: string }>( `${this.baseUrl}/v1/approvals/request`, { method: "POST", body: JSON.stringify(data) }, ); } // ── Agent profile ──────────────────────────────────── async getAgentProfile(): Promise { return this.request( `${this.baseUrl}/v1/agents/me`, ); } }