diff --git a/packages/shared/src/validators/access.ts b/packages/shared/src/validators/access.ts index 494c6842..12593d60 100644 --- a/packages/shared/src/validators/access.ts +++ b/packages/shared/src/validators/access.ts @@ -11,6 +11,7 @@ export const createCompanyInviteSchema = z.object({ allowedJoinTypes: z.enum(INVITE_JOIN_TYPES).default("both"), expiresInHours: z.number().int().min(1).max(24 * 30).optional().default(72), defaultsPayload: z.record(z.string(), z.unknown()).optional().nullable(), + agentMessage: z.string().max(4000).optional().nullable(), }); export type CreateCompanyInvite = z.infer; diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index 10ed81e7..f72bb570 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -70,4 +70,34 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("Connectivity diagnostics"); expect(text).toContain("loopback hostname"); }); + + it("includes inviter message in the onboarding text when provided", () => { + const req = buildReq("localhost:3100"); + const invite = { + id: "invite-3", + companyId: "company-1", + inviteType: "company_join", + allowedJoinTypes: "agent", + tokenHash: "hash", + defaultsPayload: { + agentMessage: "Please join as our QA lead and prioritize flaky test triage first.", + }, + expiresAt: new Date("2026-03-05T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-04T00:00:00.000Z"), + updatedAt: new Date("2026-03-04T00:00:00.000Z"), + } as const; + + const text = buildInviteOnboardingTextDocument(req, "token-789", invite as any, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }); + + expect(text).toContain("Message from inviter"); + expect(text).toContain("prioritize flaky test triage first"); + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 27e659e4..340c7d70 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -294,6 +294,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; const onboardingTextPath = `/api/invites/${token}/onboarding.txt`; + const inviteMessage = extractInviteMessage(invite); return { id: invite.id, companyId: invite.companyId, @@ -306,6 +307,7 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv onboardingTextUrl: baseUrl ? `${baseUrl}${onboardingTextPath}` : onboardingTextPath, skillIndexPath: "/api/skills/index", skillIndexUrl: baseUrl ? `${baseUrl}/api/skills/index` : "/api/skills/index", + inviteMessage, }; } @@ -406,6 +408,7 @@ function buildInviteOnboardingManifest( onboarding: { instructions: "Join as an agent, save your one-time claim secret, wait for board approval, then claim your API key and install the Paperclip skill before starting heartbeat loops.", + inviteMessage: extractInviteMessage(invite), recommendedAdapterType: "openclaw", requiredFields: { requestType: "agent", @@ -466,6 +469,7 @@ export function buildInviteOnboardingTextDocument( ) { const manifest = buildInviteOnboardingManifest(req, token, invite, opts); const onboarding = manifest.onboarding as { + inviteMessage?: string | null; registrationEndpoint: { method: string; path: string; url: string }; claimEndpointTemplate: { method: string; path: string }; textInstructions: { path: string; url: string }; @@ -486,6 +490,13 @@ export function buildInviteOnboardingTextDocument( `- allowedJoinTypes: ${invite.allowedJoinTypes}`, `- expiresAt: ${invite.expiresAt.toISOString()}`, "", + ]; + + if (onboarding.inviteMessage) { + lines.push("## Message from inviter", onboarding.inviteMessage, ""); + } + + lines.push( "## Step 1: Submit agent join request", `${onboarding.registrationEndpoint.method} ${onboarding.registrationEndpoint.url}`, "", @@ -533,7 +544,7 @@ export function buildInviteOnboardingTextDocument( "", "## Connectivity guidance", onboarding.connectivity?.guidance ?? "Ensure Paperclip is reachable from your OpenClaw runtime.", - ]; + ); if (diagnostics.length > 0) { lines.push("", "## Connectivity diagnostics"); @@ -555,6 +566,32 @@ export function buildInviteOnboardingTextDocument( return `${lines.join("\n")}\n`; } +function extractInviteMessage(invite: typeof invites.$inferSelect): string | null { + const rawDefaults = invite.defaultsPayload; + if (!rawDefaults || typeof rawDefaults !== "object" || Array.isArray(rawDefaults)) { + return null; + } + const rawMessage = (rawDefaults as Record).agentMessage; + if (typeof rawMessage !== "string") { + return null; + } + const trimmed = rawMessage.trim(); + return trimmed.length ? trimmed : null; +} + +function mergeInviteDefaults( + defaultsPayload: Record | null | undefined, + agentMessage: string | null, +): Record | null { + const merged = defaultsPayload && typeof defaultsPayload === "object" + ? { ...defaultsPayload } + : {}; + if (agentMessage) { + merged.agentMessage = agentMessage; + } + return Object.keys(merged).length ? merged : null; +} + function requestIp(req: Request) { const forwarded = req.header("x-forwarded-for"); if (forwarded) { @@ -704,6 +741,9 @@ export function accessRoutes( async (req, res) => { const companyId = req.params.companyId as string; await assertCompanyPermission(req, companyId, "users:invite"); + const normalizedAgentMessage = typeof req.body.agentMessage === "string" + ? req.body.agentMessage.trim() || null + : null; const token = createInviteToken(); const created = await db @@ -713,7 +753,7 @@ export function accessRoutes( inviteType: "company_join", tokenHash: hashToken(token), allowedJoinTypes: req.body.allowedJoinTypes, - defaultsPayload: req.body.defaultsPayload ?? null, + defaultsPayload: mergeInviteDefaults(req.body.defaultsPayload ?? null, normalizedAgentMessage), expiresAt: new Date(Date.now() + req.body.expiresInHours * 60 * 60 * 1000), invitedByUserId: req.actor.userId ?? null, }) @@ -731,13 +771,18 @@ export function accessRoutes( inviteType: created.inviteType, allowedJoinTypes: created.allowedJoinTypes, expiresAt: created.expiresAt.toISOString(), + hasAgentMessage: Boolean(normalizedAgentMessage), }, }); + const inviteSummary = toInviteSummaryResponse(req, token, created); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + onboardingTextPath: inviteSummary.onboardingTextPath, + onboardingTextUrl: inviteSummary.onboardingTextUrl, + inviteMessage: inviteSummary.inviteMessage, }); }, ); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 6b66a0f1..f43d2713 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -13,6 +13,7 @@ type InviteSummary = { onboardingTextUrl?: string; skillIndexPath?: string; skillIndexUrl?: string; + inviteMessage?: string | null; }; type AcceptInviteInput = @@ -56,6 +57,7 @@ export const accessApi = { allowedJoinTypes?: "human" | "agent" | "both"; expiresInHours?: number; defaultsPayload?: Record | null; + agentMessage?: string | null; } = {}, ) => api.post<{ @@ -64,6 +66,9 @@ export const accessApi = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + onboardingTextPath?: string; + onboardingTextUrl?: string; + inviteMessage?: string | null; }>(`/companies/${companyId}/invites`, input), getInvite: (token: string) => api.get(`/invites/${token}`), diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index 003be3e8..ebab1bbf 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -30,7 +30,10 @@ export function CompanySettings() { const [inviteLink, setInviteLink] = useState(null); const [inviteError, setInviteError] = useState(null); + const [inviteMessage, setInviteMessage] = useState(""); + const [frozenInviteMessage, setFrozenInviteMessage] = useState(null); const [copied, setCopied] = useState(false); + const [copyDelightId, setCopyDelightId] = useState(0); const generalDirty = !!selectedCompany && @@ -59,19 +62,27 @@ export function CompanySettings() { const inviteMutation = useMutation({ mutationFn: () => accessApi.createCompanyInvite(selectedCompanyId!, { - allowedJoinTypes: "both", + allowedJoinTypes: "agent", expiresInHours: 72, + agentMessage: inviteMessage.trim() || null, }), onSuccess: async (invite) => { setInviteError(null); const base = window.location.origin.replace(/\/+$/, ""); - const absoluteUrl = invite.inviteUrl.startsWith("http") - ? invite.inviteUrl - : `${base}${invite.inviteUrl}`; + const onboardingTextLink = invite.onboardingTextUrl + ?? invite.onboardingTextPath + ?? `/api/invites/${invite.token}/onboarding.txt`; + const absoluteUrl = onboardingTextLink.startsWith("http") + ? onboardingTextLink + : `${base}${onboardingTextLink}`; setInviteLink(absoluteUrl); + const submittedMessage = inviteMessage.trim() || null; + setInviteMessage(submittedMessage ?? ""); + setFrozenInviteMessage(invite.inviteMessage ?? submittedMessage); try { await navigator.clipboard.writeText(absoluteUrl); setCopied(true); + setCopyDelightId((prev) => prev + 1); setTimeout(() => setCopied(false), 2000); } catch { /* clipboard may not be available */ } queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) }); @@ -80,6 +91,15 @@ export function CompanySettings() { setInviteError(err instanceof Error ? err.message : "Failed to create invite"); }, }); + + useEffect(() => { + setInviteLink(null); + setInviteError(null); + setInviteMessage(""); + setFrozenInviteMessage(null); + setCopied(false); + setCopyDelightId(0); + }, [selectedCompanyId]); const archiveMutation = useMutation({ mutationFn: ({ companyId, @@ -250,19 +270,51 @@ export function CompanySettings() {
- Generate a link to invite humans or agents to this company. - + + Generate an agent onboarding link (`.txt`) for OpenClaw-style join flows. + +
- + +