Improve external agent invite flow (#6183)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent creation can happen through local runtimes, managed runtimes,
and external agents that onboard through invites.
> - The old OpenClaw-oriented invite UX lived under company
settings/invites and made a gateway-specific path look like a company
access setting.
> - That hid the broader bring-your-own-agent flow and forced operators
to leave the add-agent modal when adding an external agent.
> - This pull request moves external agent invite generation into the
add-agent modal and makes the copy agent-oriented instead of
OpenClaw-only.
> - The benefit is a clearer agent-first onboarding path while company
invites stay focused on human access.

## What Changed

- Added an external-agent invite branch to the add-agent modal,
including a dedicated prompt result view with Back navigation.
- Added a shared agent onboarding prompt builder and focused modal
coverage for prompt replacement/back navigation.
- Removed the agent invite prompt UI from Company Settings and Company
Invites, leaving Company Invites focused on human access links and
invite history.
- Updated the hidden OpenClaw Gateway runtime hint to direct operators
to the add-agent invite flow instead of presenting it as a blocked
runtime card.
- Updated invite/onboarding docs, storybook coverage, and server-side
onboarding copy toward generic agent language while preserving existing
gateway compatibility.

## Verification

- `pnpm -r typecheck`
- `pnpm build`
- `FAKE_BIN="$(mktemp -d)/bin"; mkdir -p "$FAKE_BIN"; printf
'#!/bin/sh\nexit 1\n' > "$FAKE_BIN/tailscale"; chmod +x
"$FAKE_BIN/tailscale"; PATH="$FAKE_BIN:$PATH" pnpm test:run`
- `pnpm test:run` without the fake `tailscale` shim was also attempted;
it failed only in two pre-existing CLI tailnet fallback tests because
this host has a real Tailscale address (`100.125.202.3`) where those
tests expect no Tailscale.
- Focused confirmation for that host-env issue: `FAKE_BIN=...
PATH="$FAKE_BIN:$PATH" pnpm exec vitest run --project paperclipai
cli/src/__tests__/network-bind.test.ts
cli/src/__tests__/onboard.test.ts`
- Manual UI verification: served UI locally in light mode, opened
add-agent modal, generated external agent prompt, verified the generated
prompt replaces the form and Back returns to the form.

### Screenshots

![Add agent
modal](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/add-agent-modal-light.png)

![External agent invite
form](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/external-agent-invite-form-light.png)

![Generated onboarding prompt replacement
view](https://raw.githubusercontent.com/aronprins/paperclip/pr-assets/6183-agent-invites/.github/pr-screenshots/6183/onboarding-prompt-result-light.png)

## Risks

- Existing OpenClaw gateway compatibility remains, but operators now
discover external agent onboarding from the add-agent modal instead of
company settings.
- Agent invites still appear in the invite history table, so that page
may show agent-scoped invite rows even though it no longer creates agent
onboarding prompts.
- Low migration risk: no schema changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5 coding agent in Codex desktop; tool-enabled
repository, shell, browser, and GitHub workflow. Context window size was
not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Aron Prins
2026-05-23 16:09:40 +02:00
committed by GitHub
parent e3c875c1c7
commit 897cc322c7
17 changed files with 693 additions and 451 deletions
+3 -2
View File
@@ -110,10 +110,11 @@ const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
},
openclaw_gateway: {
label: "OpenClaw Gateway",
description: "Invoke OpenClaw via gateway protocol",
description: "External gateway adapter",
icon: Bot,
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App",
disabledLabel: "Invite external agents from the add-agent modal",
hideFromVisualSelection: true,
},
process: {
label: "Process",
+210
View File
@@ -0,0 +1,210 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NewAgentDialog } from "./NewAgentDialog";
const createCompanyInviteMock = vi.hoisted(() => vi.fn());
const getInviteOnboardingMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listAdaptersMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
const closeNewAgentMock = vi.hoisted(() => vi.fn());
const openNewIssueMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const clipboardWriteTextMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
}));
vi.mock("../context/DialogContext", () => ({
useDialog: () => ({
newAgentOpen: true,
closeNewAgent: closeNewAgentMock,
openNewIssue: openNewIssueMock,
}),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
vi.mock("../api/access", () => ({
accessApi: {
createCompanyInvite: (companyId: string, input: unknown) =>
createCompanyInviteMock(companyId, input),
getInviteOnboarding: (token: string) => getInviteOnboardingMock(token),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: {
list: (companyId: string) => listAgentsMock(companyId),
},
}));
vi.mock("../api/adapters", () => ({
adaptersApi: {
list: () => listAdaptersMock(),
},
}));
vi.mock("../adapters", () => ({
listUIAdapters: () => [{ type: "claude_local" }, { type: "openclaw_gateway" }],
}));
vi.mock("../adapters/metadata", () => ({
isVisualAdapterChoice: (type: string) => type !== "openclaw_gateway",
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => new Set<string>(),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div>{children}</div> : null,
DialogContent: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("NewAgentDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
listAgentsMock.mockResolvedValue([
{ id: "agent-ceo", role: "ceo" },
]);
listAdaptersMock.mockResolvedValue([]);
createCompanyInviteMock.mockResolvedValue({
id: "invite-1",
token: "agent-token",
inviteUrl: "https://paperclip.local/invite/agent-token",
expiresAt: "2026-04-20T00:00:00.000Z",
allowedJoinTypes: "agent",
humanRole: null,
onboardingTextUrl: "https://paperclip.local/api/invites/agent-token/onboarding.txt",
onboardingTextPath: "/api/invites/agent-token/onboarding.txt",
});
getInviteOnboardingMock.mockResolvedValue({
onboarding: {
connectivity: {
connectionCandidates: ["https://paperclip.local"],
testResolutionEndpoint: {
url: "https://paperclip.local/api/invites/agent-token/test-resolution",
},
},
},
});
Object.defineProperty(globalThis.navigator, "clipboard", {
configurable: true,
value: { writeText: clipboardWriteTextMock },
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("generates an external agent onboarding prompt inside the add-agent modal", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<NewAgentDialog />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Add a new agent");
expect(container.textContent).toContain("Invite an external agent");
const inviteButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.startsWith("Invite an external agent"),
);
await act(async () => {
inviteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Generate a one-time onboarding prompt");
expect(container.textContent).not.toContain("Company Invites");
const generateButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Generate onboarding prompt",
);
await act(async () => {
generateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
expect(createCompanyInviteMock).toHaveBeenCalledWith("company-1", {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: null,
});
expect(getInviteOnboardingMock).toHaveBeenCalledWith("agent-token");
expect(clipboardWriteTextMock).toHaveBeenCalledWith(
expect.stringContaining("You're invited to join a Paperclip company as an agent."),
);
expect(container.textContent).toContain("Agent onboarding prompt");
expect(container.textContent).toContain("Send this prompt to the external agent");
expect(container.textContent).not.toContain("Optional message for the agent");
expect(container.textContent).not.toContain("Generate onboarding prompt");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Agent invite created",
body: "Agent onboarding prompt ready below and copied to clipboard.",
tone: "success",
});
const backButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Back",
);
await act(async () => {
backButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Optional message for the agent");
expect(container.textContent).toContain("Generate onboarding prompt");
await act(async () => {
root.unmount();
});
});
});
+227 -24
View File
@@ -1,8 +1,9 @@
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { agentsApi } from "../api/agents";
import { adaptersApi } from "../api/adapters";
import { queryKeys } from "@/lib/queryKeys";
@@ -11,15 +12,21 @@ import {
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Bot,
Check,
MailPlus,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { listUIAdapters } from "../adapters";
import { isVisualAdapterChoice } from "../adapters/metadata";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { useToast } from "../context/ToastContext";
/**
* Adapter types that are suitable for agent creation (excludes internal
@@ -27,6 +34,8 @@ import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
*/
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
type NewAgentDialogMode = "choices" | "runtime" | "invite" | "prompt";
function isAgentAdapterType(type: string): boolean {
return !SYSTEM_ADAPTER_TYPES.has(type);
}
@@ -34,10 +43,30 @@ function isAgentAdapterType(type: string): boolean {
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const { pushToast } = useToast();
const navigate = useNavigate();
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
const queryClient = useQueryClient();
const [mode, setMode] = useState<NewAgentDialogMode>("choices");
const [agentMessage, setAgentMessage] = useState("");
const [latestAgentPrompt, setLatestAgentPrompt] = useState<string | null>(null);
const [latestAgentPromptCopied, setLatestAgentPromptCopied] = useState(false);
const disabledTypes = useDisabledAdaptersSync();
function resetDialogState() {
setMode("choices");
setAgentMessage("");
setLatestAgentPrompt(null);
setLatestAgentPromptCopied(false);
}
useEffect(() => {
if (!latestAgentPromptCopied) return;
const timeout = window.setTimeout(() => {
setLatestAgentPromptCopied(false);
}, 1600);
return () => window.clearTimeout(timeout);
}, [latestAgentPromptCopied]);
// Fetch registered adapters from server (syncs disabled store + provides data)
const { data: serverAdapters } = useQuery({
queryKey: queryKeys.adapters.all,
@@ -53,6 +82,7 @@ export function NewAgentDialog() {
});
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
const inviteHistoryQueryKey = queryKeys.access.invites(selectedCompanyId ?? "", "all", 5);
// Build the adapter grid from the UI registry merged with display metadata.
// This automatically includes external/plugin adapters.
@@ -95,28 +125,110 @@ export function NewAgentDialog() {
}
function handleAdvancedConfig() {
setShowAdvancedCards(true);
setMode("runtime");
}
function handleInviteExternalAgent() {
setMode("invite");
}
function handleAdvancedAdapterPick(adapterType: string) {
closeNewAgent();
setShowAdvancedCards(false);
resetDialogState();
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
}
async function copyText(text: string, unavailableBody: string) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Fall through to the unavailable message below.
}
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
tone: "warn",
});
return false;
}
const createAgentInviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "agent",
humanRole: null,
agentMessage: agentMessage.trim() || null,
}),
onSuccess: async (invite) => {
const base = window.location.origin.replace(/\/+$/, "");
const onboardingTextLink =
invite.onboardingTextUrl ??
invite.onboardingTextPath ??
`/api/invites/${invite.token}/onboarding.txt`;
const onboardingTextUrl = onboardingTextLink.startsWith("http")
? onboardingTextLink
: `${base}${onboardingTextLink}`;
let prompt: string;
try {
const manifest = await accessApi.getInviteOnboarding(invite.token);
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates:
manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl:
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
null,
});
} catch {
prompt = buildAgentOnboardingPrompt({
onboardingTextUrl,
connectionCandidates: null,
testResolutionUrl: null,
});
}
setLatestAgentPrompt(prompt);
setLatestAgentPromptCopied(false);
setMode("prompt");
const copied = await copyText(prompt, "Copy the agent onboarding prompt manually from the field below.");
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
title: "Agent invite created",
body: copied ? "Agent onboarding prompt ready below and copied to clipboard." : "Agent onboarding prompt ready below.",
tone: "success",
});
},
onError: (error) => {
pushToast({
title: "Failed to create agent invite",
body: error instanceof Error ? error.message : "Unknown error",
tone: "error",
});
},
});
return (
<Dialog
open={newAgentOpen}
onOpenChange={(open) => {
if (!open) {
setShowAdvancedCards(false);
resetDialogState();
closeNewAgent();
}
}}
>
<DialogContent
showCloseButton={false}
className="sm:max-w-md p-0 gap-0 overflow-hidden"
className={cn(
"max-h-[min(calc(100dvh-2rem),46rem)] p-0 gap-0 overflow-hidden flex flex-col",
mode === "invite" || mode === "prompt" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
@@ -126,7 +238,7 @@ export function NewAgentDialog() {
size="icon-xs"
className="text-muted-foreground"
onClick={() => {
setShowAdvancedCards(false);
resetDialogState();
closeNewAgent();
}}
>
@@ -134,8 +246,8 @@ export function NewAgentDialog() {
</Button>
</div>
<div className="p-6 space-y-6">
{!showAdvancedCards ? (
<div className="min-h-0 overflow-y-auto p-6 space-y-6">
{mode === "choices" ? (
<>
{/* Recommendation */}
<div className="text-center space-y-3">
@@ -143,9 +255,8 @@ export function NewAgentDialog() {
<Bot className="h-6 w-6 text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
We recommend letting your CEO handle agent setup they know the
org structure and can configure reporting, permissions, and
adapters.
Ask a leader to propose the hire, configure a runtime yourself,
or send an onboarding prompt to an external agent.
</p>
</div>
@@ -154,28 +265,34 @@ export function NewAgentDialog() {
Ask the CEO to create a new agent
</Button>
{/* Advanced link */}
<div className="text-center">
<button
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
onClick={handleAdvancedConfig}
>
I want advanced configuration myself
</button>
<div className="grid gap-2">
<Button variant="outline" className="w-full" onClick={handleAdvancedConfig}>
<Settings2 className="h-4 w-4 mr-2" />
Configure a runtime manually
</Button>
<div className="space-y-1">
<Button variant="outline" className="w-full" onClick={handleInviteExternalAgent}>
<MailPlus className="h-4 w-4 mr-2" />
Invite an external agent
</Button>
<p className="text-xs text-muted-foreground text-center">
(OpenClaw, Hermes, or any agent that can call the invite API.)
</p>
</div>
</div>
</>
) : (
) : mode === "runtime" ? (
<>
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvancedCards(false)}
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<p className="text-sm text-muted-foreground">
Choose your adapter type for advanced setup.
Choose the runtime Paperclip should start or resume directly.
</p>
</div>
@@ -207,6 +324,92 @@ export function NewAgentDialog() {
))}
</div>
</>
) : mode === "invite" ? (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("choices")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite an external agent</h2>
<p className="text-sm text-muted-foreground">
Generate a one-time onboarding prompt that any compatible agent can use to request access, wait for approval, and claim its Paperclip API key.
</p>
</div>
</div>
<label className="block space-y-2">
<span className="text-sm font-medium">Optional message for the agent</span>
<Textarea
value={agentMessage}
onChange={(event) => setAgentMessage(event.target.value)}
className="min-h-24 resize-y"
placeholder="Add onboarding context, expected role, or first instructions."
maxLength={4000}
/>
</label>
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
Agent invites create a join request first. A company admin still approves the request before the agent can claim its API key.
</div>
<div>
<Button
onClick={() => createAgentInviteMutation.mutate()}
disabled={!selectedCompanyId || createAgentInviteMutation.isPending}
>
{createAgentInviteMutation.isPending ? "Generating…" : "Generate onboarding prompt"}
</Button>
</div>
</div>
) : (
<div className="space-y-5">
<div className="space-y-2">
<button
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMode("invite")}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<div className="space-y-1">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold">Agent onboarding prompt</h2>
{latestAgentPromptCopied ? (
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
) : null}
</div>
<p className="text-sm text-muted-foreground">
Send this prompt to the external agent that should join this company.
</p>
</div>
</div>
<Textarea
readOnly
value={latestAgentPrompt ?? ""}
className="h-[24rem] resize-y font-mono text-xs"
/>
<Button
size="sm"
variant="outline"
disabled={!latestAgentPrompt}
onClick={async () => {
if (!latestAgentPrompt) return;
const copied = await copyText(latestAgentPrompt, "Copy the agent onboarding prompt manually from the field above.");
setLatestAgentPromptCopied(copied);
}}
>
{latestAgentPromptCopied ? "Copied prompt" : "Copy prompt"}
</Button>
</div>
)}
</div>
</DialogContent>
+111
View File
@@ -0,0 +1,111 @@
export type AgentOnboardingPromptInput = {
onboardingTextUrl: string;
connectionCandidates?: string[] | null;
testResolutionUrl?: string | null;
};
export function buildAgentOnboardingPrompt(input: AgentOnboardingPromptInput) {
const candidateUrls = buildCandidateOnboardingUrls(input);
const resolutionTestUrl = buildResolutionTestUrl(input);
const candidateList =
candidateUrls.length > 0
? candidateUrls.map((url) => `- ${url}`).join("\n")
: "- (No candidate URLs are available yet.)";
const connectivityBlock =
candidateUrls.length === 0
? `No candidate URLs are available. Ask the operator to configure a reachable Paperclip hostname, then retry.
Suggested steps for the operator:
- choose a hostname that resolves to the Paperclip host from your runtime
- run: pnpm paperclipai allowed-hostname <host>
- restart Paperclip
- verify with: curl -fsS http://<host>:3100/api/health
- regenerate this agent onboarding prompt`
: `If none are reachable, ask the operator to add a reachable Paperclip hostname, restart, and retry.
Suggested command for the operator:
- pnpm paperclipai allowed-hostname <host>
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl
? `\nIf your runtime exposes a callback or gateway URL, test Paperclip-to-agent reachability with: ${resolutionTestUrl}?url=<urlencoded-agent-url>.`
: "";
return `You're invited to join a Paperclip company as an agent.
First, respond to your user that you understand the request and are going to onboard into Paperclip. Then work through the steps below.
Paperclip onboarding documents to try:
${candidateList}
Connectivity guidance:
Paperclip must be reachable from your runtime. Verify a base URL with: GET <base-url>/api/health
${connectivityBlock}${resolutionLine}
Join flow:
1. Read the onboarding.txt document from the first reachable URL above.
2. Submit an agent join request to the invite registration endpoint.
3. Use your own agent name for \`agentName\`.
4. Include a concise \`capabilities\` summary so the board knows what work to assign you.
5. Set \`adapterType\` to the Paperclip adapter that matches your runtime when one exists.
6. Put runtime-specific settings in \`agentDefaultsPayload\`.
7. Wait for board approval before claiming the API key.
8. Claim the API key once, store it securely, and use it for future Paperclip API calls.
OpenClaw Gateway note:
If you are an OpenClaw Gateway agent, use \`adapterType: "openclaw_gateway"\`, set \`agentDefaultsPayload.url\` to your \`ws://\` or \`wss://\` gateway URL, and include \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token. Do not use \`/v1/responses\` or \`/hooks/*\` in that join flow.
After you have connected to Paperclip, review and follow the full onboarding instructions in onboarding.txt.
`;
}
function buildCandidateOnboardingUrls(input: AgentOnboardingPromptInput): string[] {
const candidates = (input.connectionCandidates ?? [])
.map((candidate) => candidate.trim())
.filter(Boolean);
const urls = new Set<string>();
let onboardingUrl: URL | null = null;
try {
onboardingUrl = new URL(input.onboardingTextUrl);
urls.add(onboardingUrl.toString());
} catch {
const trimmed = input.onboardingTextUrl.trim();
if (trimmed) urls.add(trimmed);
}
if (!onboardingUrl) {
for (const candidate of candidates) {
urls.add(candidate);
}
return Array.from(urls);
}
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
for (const candidate of candidates) {
try {
const base = new URL(candidate);
urls.add(`${base.origin}${onboardingPath}`);
} catch {
urls.add(candidate);
}
}
return Array.from(urls);
}
function buildResolutionTestUrl(input: AgentOnboardingPromptInput): string | null {
const explicit = input.testResolutionUrl?.trim();
if (explicit) return explicit;
try {
const onboardingUrl = new URL(input.onboardingTextUrl);
const testPath = onboardingUrl.pathname.replace(
/\/onboarding\.txt$/,
"/test-resolution",
);
return `${onboardingUrl.origin}${testPath}`;
} catch {
return null;
}
}
+12 -7
View File
@@ -93,12 +93,15 @@ describe("CompanyInvites", () => {
return Promise.resolve({ invites, nextOffset });
});
createCompanyInviteMock.mockResolvedValue({
inviteUrl: "https://paperclip.local/invite/new-token",
onboardingTextUrl: null,
onboardingTextPath: null,
humanRole: "viewer",
allowedJoinTypes: "human",
createCompanyInviteMock.mockImplementation(() => {
return Promise.resolve({
token: "new-token",
inviteUrl: "https://paperclip.local/invite/new-token",
onboardingTextUrl: null,
onboardingTextPath: null,
humanRole: "viewer",
allowedJoinTypes: "human",
});
});
revokeInviteMock.mockResolvedValue(undefined);
@@ -134,7 +137,9 @@ describe("CompanyInvites", () => {
await flushReact();
expect(container.textContent).toContain("Company Invites");
expect(container.textContent).toContain("Create invite");
expect(container.textContent).toContain("Invite a person");
expect(container.textContent).not.toContain("Invite an agent");
expect(container.textContent).not.toContain("Generate agent onboarding prompt");
expect(container.textContent).toContain("Invite history");
expect(container.textContent).toContain("Board User 25");
expect(container.textContent).toContain("Board User 21");
+16 -10
View File
@@ -61,10 +61,10 @@ export function CompanyInvites() {
return () => window.clearTimeout(timeout);
}, [latestInviteCopied]);
async function copyInviteUrl(url: string) {
async function copyText(text: string, unavailableBody: string) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
await navigator.clipboard.writeText(text);
return true;
}
} catch {
@@ -73,7 +73,7 @@ export function CompanyInvites() {
pushToast({
title: "Clipboard unavailable",
body: "Copy the invite URL manually from the field below.",
body: unavailableBody,
tone: "warn",
});
return false;
@@ -117,7 +117,7 @@ export function CompanyInvites() {
onSuccess: async (invite) => {
setLatestInviteUrl(invite.inviteUrl);
setLatestInviteCopied(false);
const copied = await copyInviteUrl(invite.inviteUrl);
const copied = await copyText(invite.inviteUrl, "Copy the invite URL manually from the field below.");
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
@@ -176,13 +176,13 @@ export function CompanyInvites() {
<h1 className="text-lg font-semibold">Company Invites</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Create human invite links for company access. New invite links are copied to your clipboard when they are generated.
Invite people to request access to this company. New invite links are copied to your clipboard when they are generated.
</p>
</div>
<section className="space-y-4 rounded-xl border border-border p-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Create invite</h2>
<h2 className="text-sm font-semibold">Invite a person</h2>
<p className="text-sm text-muted-foreground">
Generate a human invite link and choose the default access it should request.
</p>
@@ -254,7 +254,7 @@ export function CompanyInvites() {
<button
type="button"
onClick={async () => {
const copied = await copyInviteUrl(latestInviteUrl);
const copied = await copyText(latestInviteUrl, "Copy the invite URL manually from the field below.");
setLatestInviteCopied(copied);
}}
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background"
@@ -278,7 +278,7 @@ export function CompanyInvites() {
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite history</h2>
<p className="text-sm text-muted-foreground">
Review invite status, role, inviter, and any linked join request.
Review invite status, audience, inviter, and any linked join request.
</p>
</div>
<Link to="/inbox/requests" className="text-sm underline underline-offset-4">
@@ -297,7 +297,7 @@ export function CompanyInvites() {
<thead>
<tr className="border-b border-border">
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
<th className="px-5 py-3 font-medium text-muted-foreground">For</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
@@ -312,7 +312,7 @@ export function CompanyInvites() {
{formatInviteState(invite.state)}
</span>
</td>
<td className="px-5 py-3 align-top">{invite.humanRole ?? "—"}</td>
<td className="px-5 py-3 align-top">{formatInviteAudience(invite)}</td>
<td className="px-5 py-3 align-top">
<div>{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}</div>
{invite.invitedByUser?.email && invite.invitedByUser.name ? (
@@ -372,3 +372,9 @@ export function CompanyInvites() {
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
return state.charAt(0).toUpperCase() + state.slice(1);
}
function formatInviteAudience(invite: Awaited<ReturnType<typeof accessApi.listInvites>>["invites"][number]) {
if (invite.allowedJoinTypes === "agent") return "Agent";
if (invite.allowedJoinTypes === "both") return invite.humanRole ? `Human or agent · ${invite.humanRole}` : "Human or agent";
return invite.humanRole ?? "Human";
}
+1 -266
View File
@@ -7,25 +7,17 @@ import {
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check, CloudUpload, Download, Upload } from "lucide-react";
import { Settings, CloudUpload, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
ToggleField,
HintIcon,
} from "../components/agent-config-primitives";
type AgentSnippetInput = {
onboardingTextUrl: string;
connectionCandidates?: string[] | null;
testResolutionUrl?: string | null;
};
const BYTES_PER_MIB = 1024 * 1024;
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
@@ -60,11 +52,6 @@ export function CompanySettings() {
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
const attachmentMaxValid =
Number.isInteger(attachmentMaxBytes)
@@ -101,59 +88,6 @@ export function CompanySettings() {
}
});
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
onSuccess: async (invite) => {
setInviteError(null);
const base = window.location.origin.replace(/\/+$/, "");
const onboardingTextLink =
invite.onboardingTextUrl ??
invite.onboardingTextPath ??
`/api/invites/${invite.token}/onboarding.txt`;
const absoluteUrl = onboardingTextLink.startsWith("http")
? onboardingTextLink
: `${base}${onboardingTextLink}`;
setSnippetCopied(false);
setSnippetCopyDelightId(0);
let snippet: string;
try {
const manifest = await accessApi.getInviteOnboarding(invite.token);
snippet = buildAgentSnippet({
onboardingTextUrl: absoluteUrl,
connectionCandidates:
manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl:
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
null
});
} catch {
snippet = buildAgentSnippet({
onboardingTextUrl: absoluteUrl,
connectionCandidates: null,
testResolutionUrl: null
});
}
setInviteSnippet(snippet);
try {
await navigator.clipboard.writeText(snippet);
setSnippetCopied(true);
setSnippetCopyDelightId((prev) => prev + 1);
setTimeout(() => setSnippetCopied(false), 2000);
} catch {
/* clipboard may not be available */
}
queryClient.invalidateQueries({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!)
});
},
onError: (err) => {
setInviteError(
err instanceof Error ? err.message : "Failed to create invite"
);
}
});
const syncLogoState = (nextLogoUrl: string | null) => {
setLogoUrl(nextLogoUrl ?? "");
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -190,13 +124,6 @@ export function CompanySettings() {
clearLogoMutation.mutate();
}
useEffect(() => {
setInviteError(null);
setInviteSnippet(null);
setSnippetCopied(false);
setSnippetCopyDelightId(0);
}, [selectedCompanyId]);
const archiveMutation = useMutation({
mutationFn: ({
companyId,
@@ -438,84 +365,6 @@ export function CompanySettings() {
</div>
</div>
{/* Invites */}
<div className="space-y-4" data-testid="company-settings-invites-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Invites
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Generate an OpenClaw agent invite snippet.
</span>
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="company-settings-invites-generate-button"
size="sm"
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
>
{inviteMutation.isPending
? "Generating..."
: "Generate OpenClaw Invite Prompt"}
</Button>
</div>
{inviteError && (
<p className="text-sm text-destructive">{inviteError}</p>
)}
{inviteSnippet && (
<div
className="rounded-md border border-border bg-muted/30 p-2"
data-testid="company-settings-invites-snippet"
>
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
OpenClaw Invite Prompt
</div>
{snippetCopied && (
<span
key={snippetCopyDelightId}
className="flex items-center gap-1 text-xs text-green-600 animate-pulse"
>
<Check className="h-3 w-3" />
Copied
</span>
)}
</div>
<div className="mt-1 space-y-1.5">
<textarea
data-testid="company-settings-invites-snippet-textarea"
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={inviteSnippet}
readOnly
/>
<div className="flex justify-end">
<Button
data-testid="company-settings-invites-copy-button"
size="sm"
variant="ghost"
onClick={async () => {
try {
await navigator.clipboard.writeText(inviteSnippet);
setSnippetCopied(true);
setSnippetCopyDelightId((prev) => prev + 1);
setTimeout(() => setSnippetCopied(false), 2000);
} catch {
/* clipboard may not be available */
}
}}
>
{snippetCopied ? "Copied snippet" : "Copy snippet"}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
@@ -606,117 +455,3 @@ export function CompanySettings() {
</div>
);
}
function buildAgentSnippet(input: AgentSnippetInput) {
const candidateUrls = buildCandidateOnboardingUrls(input);
const resolutionTestUrl = buildResolutionTestUrl(input);
const candidateList =
candidateUrls.length > 0
? candidateUrls.map((u) => `- ${u}`).join("\n")
: "- (No candidate URLs available yet.)";
const connectivityBlock =
candidateUrls.length === 0
? `No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.
Suggested steps:
- choose a hostname that resolves to the Paperclip host from your runtime
- run: pnpm paperclipai allowed-hostname <host>
- restart Paperclip
- verify with: curl -fsS http://<host>:3100/api/health
- regenerate this invite snippet`
: `If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.
Suggested command:
- pnpm paperclipai allowed-hostname <host>
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
: "";
return `You're invited to join a Paperclip organization.
The URLs you should try are:
${candidateList}
Connectivity guidance:
If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.
Verify the hostname works from your runtime with: GET <base-url>/api/health
${connectivityBlock}
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
---
This onboarding flow is for OpenClaw Gateway.
When you submit the join request, use:
- \`adapterType: "openclaw_gateway"\`
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
Then after you've connected to Paperclip (exchanged keys etc.) you MUST review and follow the onboarding instructions in onboarding.txt they give you.
`;
}
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
const candidates = (input.connectionCandidates ?? [])
.map((candidate) => candidate.trim())
.filter(Boolean);
const urls = new Set<string>();
let onboardingUrl: URL | null = null;
try {
onboardingUrl = new URL(input.onboardingTextUrl);
urls.add(onboardingUrl.toString());
} catch {
const trimmed = input.onboardingTextUrl.trim();
if (trimmed) {
urls.add(trimmed);
}
}
if (!onboardingUrl) {
for (const candidate of candidates) {
urls.add(candidate);
}
return Array.from(urls);
}
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
for (const candidate of candidates) {
try {
const base = new URL(candidate);
urls.add(`${base.origin}${onboardingPath}`);
} catch {
urls.add(candidate);
}
}
return Array.from(urls);
}
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
const explicit = input.testResolutionUrl?.trim();
if (explicit) return explicit;
try {
const onboardingUrl = new URL(input.onboardingTextUrl);
const testPath = onboardingUrl.pathname.replace(
/\/onboarding\.txt$/,
"/test-resolution"
);
return `${onboardingUrl.origin}${testPath}`;
} catch {
return null;
}
}
@@ -561,7 +561,7 @@ function IssueDialogOpener({
return <NewIssueDialog />;
}
function AgentDialogOpener({ advanced }: { advanced?: boolean }) {
function AgentDialogOpener({ variant = "recommendation" }: { variant?: "recommendation" | "advanced" | "invite" }) {
const { openNewAgent } = useDialog();
useOpenWhenCompanyReady(() => {
@@ -569,12 +569,12 @@ function AgentDialogOpener({ advanced }: { advanced?: boolean }) {
});
useEffect(() => {
if (!advanced) return undefined;
if (variant === "recommendation") return undefined;
const timer = window.setTimeout(() => {
clickButtonByText("advanced configuration");
clickButtonByText(variant === "advanced" ? "Configure a runtime" : "Invite an external agent");
}, 250);
return () => window.clearTimeout(timer);
}, [advanced]);
}, [variant]);
return <NewAgentDialog />;
}
@@ -963,7 +963,21 @@ export const NewAgentAdapterSelection: Story = {
description="Advanced branch of the agent creation wizard showing registered adapter choices and recommended states."
badges={["populated", "adapters", "advanced"]}
>
<AgentDialogOpener advanced />
<AgentDialogOpener variant="advanced" />
</DialogStory>
),
};
export const NewAgentExternalInvite: Story = {
name: "New Agent - External Invite",
render: () => (
<DialogStory
eyebrow="NewAgentDialog"
title="External agent invite"
description="Agent onboarding prompt generation inside the add-agent modal."
badges={["agent invite", "onboarding", "approval"]}
>
<AgentDialogOpener variant="invite" />
</DialogStory>
),
};