feat(openclaw): add adapter hire-approved hooks

This commit is contained in:
Dotta
2026-03-06 08:17:42 -06:00
parent 67bc601258
commit 3369a9e685
12 changed files with 512 additions and 3 deletions

View File

@@ -13,6 +13,8 @@ export type {
AdapterEnvironmentTestContext,
AdapterSessionCodec,
AdapterModel,
HireApprovedPayload,
HireApprovedHookResult,
ServerAdapterModule,
TranscriptEntry,
StdoutLineParser,

View File

@@ -119,6 +119,27 @@ export interface AdapterEnvironmentTestContext {
};
}
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
export interface HireApprovedPayload {
companyId: string;
agentId: string;
agentName: string;
adapterType: string;
/** "join_request" | "approval" */
source: "join_request" | "approval";
sourceId: string;
approvedAt: string;
/** Canonical operator-facing message for cloud adapters to show the user. */
message: string;
}
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
export interface HireApprovedHookResult {
ok: boolean;
error?: string;
detail?: Record<string, unknown>;
}
export interface ServerAdapterModule {
type: string;
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
@@ -128,6 +149,14 @@ export interface ServerAdapterModule {
models?: AdapterModel[];
listModels?: () => Promise<AdapterModel[]>;
agentConfigurationDoc?: string;
/**
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
*/
onHireApproved?: (
payload: HireApprovedPayload,
adapterConfig: Record<string, unknown>,
) => Promise<HireApprovedHookResult>;
}
// ---------------------------------------------------------------------------

View File

@@ -29,4 +29,10 @@ Session routing fields:
Operational fields:
- timeoutSec (number, optional): SSE request timeout in seconds (default 0 = no adapter timeout)
Hire-approved callback fields (optional):
- hireApprovedCallbackUrl (string): callback endpoint invoked when this agent is approved/hired
- hireApprovedCallbackMethod (string): HTTP method for the callback (default POST)
- hireApprovedCallbackAuthHeader (string): Authorization header value for callback requests
- hireApprovedCallbackHeaders (object): extra headers merged into callback requests
`;

View File

@@ -0,0 +1,77 @@
import type { HireApprovedPayload, HireApprovedHookResult } from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
const HIRE_CALLBACK_TIMEOUT_MS = 10_000;
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
/**
* OpenClaw adapter lifecycle hook: when an agent is approved/hired, POST the payload to a
* configured callback URL so the cloud operator can notify the user (e.g. "you're hired").
* Best-effort; failures are non-fatal to the approval flow.
*/
export async function onHireApproved(
payload: HireApprovedPayload,
adapterConfig: Record<string, unknown>,
): Promise<HireApprovedHookResult> {
const config = parseObject(adapterConfig);
const url = nonEmpty(config.hireApprovedCallbackUrl);
if (!url) {
return { ok: true };
}
const method = (asString(config.hireApprovedCallbackMethod, "POST").trim().toUpperCase()) || "POST";
const authHeader = nonEmpty(config.hireApprovedCallbackAuthHeader) ?? nonEmpty(config.webhookAuthHeader);
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (authHeader && !headers.authorization && !headers.Authorization) {
headers.Authorization = authHeader;
}
const extraHeaders = parseObject(config.hireApprovedCallbackHeaders) as Record<string, unknown>;
for (const [key, value] of Object.entries(extraHeaders)) {
if (typeof value === "string" && value.trim().length > 0) {
headers[key] = value;
}
}
const body = JSON.stringify({
...payload,
event: "hire_approved",
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), HIRE_CALLBACK_TIMEOUT_MS);
try {
const response = await fetch(url, {
method,
headers,
body,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const text = await response.text().catch(() => "");
return {
ok: false,
error: `HTTP ${response.status} ${response.statusText}`,
detail: { status: response.status, statusText: response.statusText, body: text.slice(0, 500) },
};
}
return { ok: true };
} catch (err) {
clearTimeout(timeout);
const message = err instanceof Error ? err.message : String(err);
const cause = err instanceof Error ? err.cause : undefined;
return {
ok: false,
error: message,
detail: cause != null ? { cause: String(cause) } : undefined,
};
}
}

View File

@@ -1,3 +1,4 @@
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export { parseOpenClawResponse, isOpenClawUnknownSessionError } from "./parse.js";
export { onHireApproved } from "./hire-hook.js";