Add CEO OpenClaw invite endpoint and update onboarding UX

This commit is contained in:
Dotta
2026-03-07 18:19:06 -06:00
parent 2223afa0e9
commit 0233525e99
13 changed files with 608 additions and 120 deletions

View File

@@ -18,20 +18,28 @@ Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
4. Use the agent snippet flow.
- Copy the snippet from company settings.
4. Use the OpenClaw invite prompt flow.
- In the Invites section, click `Generate OpenClaw Invite Prompt`.
- Copy the generated prompt from `OpenClaw Invite Prompt`.
- Paste it into OpenClaw main chat as one message.
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
Security/control note:
- The OpenClaw invite prompt is created from a controlled endpoint:
- `POST /api/companies/{companyId}/openclaw/invite-prompt`
- board users with invite permission can call it
- agent callers are limited to the company CEO agent
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
6. Gateway preflight (required before task tests).
- Confirm the created agent uses `openclaw_gateway` (not `openclaw`).
- Confirm gateway URL is `ws://...` or `wss://...`.
- Confirm gateway token is non-trivial (not empty / not 1-char placeholder).
- The OpenClaw Gateway adapter UI should not expose `disableDeviceAuth` for normal onboarding.
- Confirm pairing mode is explicit:
- recommended default: `adapterConfig.disableDeviceAuth` is false/absent and `adapterConfig.devicePrivateKeyPem` is present
- fallback only: `adapterConfig.disableDeviceAuth=true` when pairing cannot be supported in that environment
- required default: device auth enabled (`adapterConfig.disableDeviceAuth` false/absent) with persisted `adapterConfig.devicePrivateKeyPem`
- do not rely on `disableDeviceAuth` for normal onboarding
- If you can run API checks with board auth:
```bash
AGENT_ID="<newly-created-agent-id>"
@@ -40,8 +48,9 @@ curl -sS -H "Cookie: $PAPERCLIP_COOKIE" "http://127.0.0.1:3100/api/agents/$AGENT
- Expected: `adapterType=openclaw_gateway`, `tokenLen >= 16`, `hasDeviceKey=true`, and `disableDeviceAuth=false`.
Pairing handshake note:
- The adapter now attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
- If auto-pair cannot complete, the first gateway run may still return `pairing required` once for a new device key.
- Clean run expectation: first task should succeed without manual pairing commands.
- The adapter attempts one automatic pairing approval + retry on first `pairing required` (when shared gateway auth token/password is valid).
- If auto-pair cannot complete (for example token mismatch or no pending request), the first gateway run may still return `pairing required`.
- This is a separate approval from Paperclip invite approval. You must approve the pending device in OpenClaw itself.
- Approve it in OpenClaw, then retry the task.
- For local docker smoke, you can approve from host:

View File

@@ -197,6 +197,7 @@ export {
updateBudgetSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
@@ -206,6 +207,7 @@ export {
type UpdateBudget,
type CreateAssetImageMetadata,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,

View File

@@ -15,6 +15,14 @@ export const createCompanyInviteSchema = z.object({
export type CreateCompanyInvite = z.infer<typeof createCompanyInviteSchema>;
export const createOpenClawInvitePromptSchema = z.object({
agentMessage: z.string().max(4000).optional().nullable(),
});
export type CreateOpenClawInvitePrompt = z.infer<
typeof createOpenClawInvitePromptSchema
>;
export const acceptInviteSchema = z.object({
requestType: z.enum(JOIN_REQUEST_TYPES),
agentName: z.string().min(1).max(120).optional(),

View File

@@ -119,12 +119,14 @@ export {
export {
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
acceptInviteSchema,
listJoinRequestsQuerySchema,
claimJoinRequestApiKeySchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
type AcceptInvite,
type ListJoinRequestsQuery,
type ClaimJoinRequestApiKey,

View File

@@ -0,0 +1,181 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { accessRoutes } from "../routes/access.js";
import { errorHandler } from "../middleware/index.js";
const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
canUser: vi.fn(),
isInstanceAdmin: vi.fn(),
getMembership: vi.fn(),
ensureMembership: vi.fn(),
listMembers: vi.fn(),
setMemberPermissions: vi.fn(),
promoteInstanceAdmin: vi.fn(),
demoteInstanceAdmin: vi.fn(),
listUserCompanyAccess: vi.fn(),
setUserCompanyAccess: vi.fn(),
setPrincipalGrants: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
deduplicateAgentName: vi.fn(),
logActivity: mockLogActivity,
notifyHireApproved: vi.fn(),
}));
function createDbStub() {
const createdInvite = {
id: "invite-1",
companyId: "company-1",
inviteType: "company_join",
allowedJoinTypes: "agent",
defaultsPayload: null,
expiresAt: new Date("2026-03-07T00:10:00.000Z"),
invitedByUserId: null,
tokenHash: "hash",
revokedAt: null,
acceptedAt: null,
createdAt: new Date("2026-03-07T00:00:00.000Z"),
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
};
const returning = vi.fn().mockResolvedValue([createdInvite]);
const values = vi.fn().mockReturnValue({ returning });
const insert = vi.fn().mockReturnValue({ values });
return {
insert,
};
}
function createApp(actor: Record<string, unknown>, db: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use(
"/api",
accessRoutes(db as any, {
deploymentMode: "local_trusted",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}),
);
app.use(errorHandler);
return app;
}
describe("POST /companies/:companyId/openclaw/invite-prompt", () => {
beforeEach(() => {
mockAccessService.canUser.mockResolvedValue(false);
mockAgentService.getById.mockReset();
mockLogActivity.mockResolvedValue(undefined);
});
it("rejects non-CEO agent callers", async () => {
const db = createDbStub();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
});
const app = createApp(
{
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
});
it("allows CEO agent callers and creates an agent-only invite", async () => {
const db = createDbStub();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "ceo",
});
const app = createApp(
{
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({ agentMessage: "Join and configure OpenClaw gateway." });
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
expect(typeof res.body.token).toBe("string");
expect(res.body.onboardingTextPath).toContain("/api/invites/");
});
it("allows board callers with invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(true);
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(201);
expect(res.body.allowedJoinTypes).toBe("agent");
});
it("rejects board callers without invite permission", async () => {
const db = createDbStub();
mockAccessService.canUser.mockResolvedValue(false);
const app = createApp(
{
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "session",
isInstanceAdmin: false,
},
db,
);
const res = await request(app)
.post("/api/companies/company-1/openclaw/invite-prompt")
.send({});
expect(res.status).toBe(403);
expect(res.body.error).toBe("Permission denied");
});
});

View File

@@ -21,6 +21,7 @@ import {
acceptInviteSchema,
claimJoinRequestApiKeySchema,
createCompanyInviteSchema,
createOpenClawInvitePromptSchema,
listJoinRequestsQuerySchema,
updateMemberPermissionsSchema,
updateUserCompanyAccessSchema,
@@ -1942,45 +1943,49 @@ export function accessRoutes(
if (!allowed) throw forbidden("Permission denied");
}
router.get("/skills/index", (_req, res) => {
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
{
name: "paperclip-create-agent",
path: "/api/skills/paperclip-create-agent"
async function assertCanGenerateOpenClawInvitePrompt(
req: Request,
companyId: string
) {
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent") {
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await agents.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role !== "ceo") {
throw forbidden("Only CEO agents can generate OpenClaw invite prompts");
}
return;
}
if (req.actor.type !== "board") throw unauthorized();
if (isLocalImplicit(req)) return;
const allowed = await access.canUser(companyId, req.actor.userId, "users:invite");
if (!allowed) throw forbidden("Permission denied");
}
]
});
});
router.get("/skills/:skillName", (req, res) => {
const skillName = (req.params.skillName as string).trim().toLowerCase();
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
res.type("text/markdown").send(markdown);
});
router.post(
"/companies/:companyId/invites",
validate(createCompanyInviteSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "users:invite");
async function createCompanyInviteForCompany(input: {
req: Request;
companyId: string;
allowedJoinTypes: "human" | "agent" | "both";
defaultsPayload?: Record<string, unknown> | null;
agentMessage?: string | null;
}) {
const normalizedAgentMessage =
typeof req.body.agentMessage === "string"
? req.body.agentMessage.trim() || null
typeof input.agentMessage === "string"
? input.agentMessage.trim() || null
: null;
const insertValues = {
companyId,
companyId: input.companyId,
inviteType: "company_join" as const,
allowedJoinTypes: req.body.allowedJoinTypes,
allowedJoinTypes: input.allowedJoinTypes,
defaultsPayload: mergeInviteDefaults(
req.body.defaultsPayload ?? null,
input.defaultsPayload ?? null,
normalizedAgentMessage
),
expiresAt: companyInviteExpiresAt(),
invitedByUserId: req.actor.userId ?? null
invitedByUserId: input.req.actor.userId ?? null
};
let token: string | null = null;
@@ -2006,11 +2011,46 @@ export function accessRoutes(
}
}
if (!token || !created) {
throw conflict(
"Failed to generate a unique invite token. Please retry."
);
throw conflict("Failed to generate a unique invite token. Please retry.");
}
return { token, created, normalizedAgentMessage };
}
router.get("/skills/index", (_req, res) => {
res.json({
skills: [
{ name: "paperclip", path: "/api/skills/paperclip" },
{
name: "paperclip-create-agent",
path: "/api/skills/paperclip-create-agent"
}
]
});
});
router.get("/skills/:skillName", (req, res) => {
const skillName = (req.params.skillName as string).trim().toLowerCase();
const markdown = readSkillMarkdown(skillName);
if (!markdown) throw notFound("Skill not found");
res.type("text/markdown").send(markdown);
});
router.post(
"/companies/:companyId/invites",
validate(createCompanyInviteSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCompanyPermission(req, companyId, "users:invite");
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: req.body.allowedJoinTypes,
defaultsPayload: req.body.defaultsPayload ?? null,
agentMessage: req.body.agentMessage ?? null
});
await logActivity(db, {
companyId,
actorType: req.actor.type === "agent" ? "agent" : "user",
@@ -2041,6 +2081,51 @@ export function accessRoutes(
}
);
router.post(
"/companies/:companyId/openclaw/invite-prompt",
validate(createOpenClawInvitePromptSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertCanGenerateOpenClawInvitePrompt(req, companyId);
const { token, created, normalizedAgentMessage } =
await createCompanyInviteForCompany({
req,
companyId,
allowedJoinTypes: "agent",
defaultsPayload: null,
agentMessage: req.body.agentMessage ?? null
});
await logActivity(db, {
companyId,
actorType: req.actor.type === "agent" ? "agent" : "user",
actorId:
req.actor.type === "agent"
? req.actor.agentId ?? "unknown-agent"
: req.actor.userId ?? "board",
action: "invite.openclaw_prompt_created",
entityType: "invite",
entityId: created.id,
details: {
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
});
}
);
router.get("/invites/:token", async (req, res) => {
const token = (req.params.token as string).trim();
if (!token) throw notFound("Invite not found");

View File

@@ -91,6 +91,30 @@ Workspace rules:
- For repo-only setup, omit `cwd` and provide `repoUrl`.
- Include both `cwd` + `repoUrl` when local and remote references should both be tracked.
## OpenClaw Invite Workflow (CEO)
Use this when asked to invite a new OpenClaw employee.
1. Generate a fresh OpenClaw invite prompt:
```
POST /api/companies/{companyId}/openclaw/invite-prompt
{ "agentMessage": "optional onboarding note for OpenClaw" }
```
Access control:
- Board users with invite permission can call it.
- Agent callers: only the company CEO agent can call it.
2. Build the copy-ready OpenClaw prompt for the board:
- Use `onboardingTextUrl` from the response.
- Ask the board to paste that prompt into OpenClaw.
- If the issue includes an OpenClaw URL (for example `ws://127.0.0.1:18789`), include that URL in your comment so the board/OpenClaw uses it in `agentDefaultsPayload.url`.
3. Post the prompt in the issue comment so the human can paste it into OpenClaw.
4. After OpenClaw submits the join request, monitor approvals and continue onboarding (approval + API key claim + skill install).
## Critical Rules
- **Always checkout** before working. Never PATCH to `in_progress` manually.
@@ -206,6 +230,7 @@ PATCH /api/agents/{agentId}/instructions-path
| Update task | `PATCH /api/issues/:issueId` (optional `comment` field) |
| Add comment | `POST /api/issues/:issueId/comments` |
| Create subtask | `POST /api/companies/:companyId/issues` |
| Generate OpenClaw invite prompt (CEO) | `POST /api/companies/:companyId/openclaw/invite-prompt` |
| Create project | `POST /api/companies/:companyId/projects` |
| Create project workspace | `POST /api/projects/:projectId/workspaces` |
| Set instructions path | `PATCH /api/agents/:agentId/instructions-path` |

View File

@@ -280,6 +280,23 @@ GET /api/companies/{companyId}/dashboard — health summary: agent/task counts,
Use the dashboard for situational awareness, especially if you're a manager or CEO.
## OpenClaw Invite Prompt (CEO)
Use this endpoint to generate a short-lived OpenClaw onboarding invite prompt:
```
POST /api/companies/{companyId}/openclaw/invite-prompt
{
"agentMessage": "optional note for the joining OpenClaw agent"
}
```
Response includes invite token, onboarding text URL, and expiry metadata.
Access is intentionally constrained:
- board users with invite permission
- CEO agent only (non-CEO agents are rejected)
---
## Setting Agent Instructions Path
@@ -505,6 +522,7 @@ Terminal states: `done`, `cancelled`
| GET | `/api/goals/:goalId` | Goal details |
| POST | `/api/companies/:companyId/goals` | Create goal |
| PATCH | `/api/goals/:goalId` | Update goal |
| POST | `/api/companies/:companyId/openclaw/invite-prompt` | Generate OpenClaw invite prompt (CEO/board only) |
### Approvals, Costs, Activity, Dashboard

View File

@@ -64,6 +64,17 @@ type BoardClaimStatus = {
claimedByUserId: string | null;
};
type CompanyInviteCreated = {
id: string;
token: string;
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
onboardingTextPath?: string;
onboardingTextUrl?: string;
inviteMessage?: string | null;
};
export const accessApi = {
createCompanyInvite: (
companyId: string,
@@ -73,16 +84,18 @@ export const accessApi = {
agentMessage?: string | null;
} = {},
) =>
api.post<{
id: string;
token: string;
inviteUrl: string;
expiresAt: string;
allowedJoinTypes: "human" | "agent" | "both";
onboardingTextPath?: string;
onboardingTextUrl?: string;
inviteMessage?: string | null;
}>(`/companies/${companyId}/invites`, input),
api.post<CompanyInviteCreated>(`/companies/${companyId}/invites`, input),
createOpenClawInvitePrompt: (
companyId: string,
input: {
agentMessage?: string | null;
} = {},
) =>
api.post<CompanyInviteCreated>(
`/companies/${companyId}/openclaw/invite-prompt`,
input,
),
getInvite: (token: string) => api.get<InviteSummary>(`/invites/${token}`),
getInviteOnboarding: (token: string) =>

View File

@@ -1,3 +1,4 @@
import { useState, type ComponentType } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
@@ -9,12 +10,77 @@ import {
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Bot, Sparkles } from "lucide-react";
import {
ArrowLeft,
Bot,
Code,
MousePointer2,
Sparkles,
Terminal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
type AdvancedAdapterType =
| "claude_local"
| "codex_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "openclaw_gateway";
const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType;
label: string;
desc: string;
icon: ComponentType<{ className?: string }>;
recommended?: boolean;
}> = [
{
value: "claude_local",
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
recommended: true,
},
{
value: "codex_local",
label: "Codex",
icon: Code,
desc: "Local Codex agent",
recommended: true,
},
{
value: "opencode_local",
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent",
},
{
value: "pi_local",
label: "Pi",
icon: Terminal,
desc: "Local Pi agent",
},
{
value: "cursor",
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent",
},
{
value: "openclaw_gateway",
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol",
},
];
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
@@ -34,15 +100,23 @@ export function NewAgentDialog() {
}
function handleAdvancedConfig() {
setShowAdvancedCards(true);
}
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
closeNewAgent();
navigate("/agents/new");
setShowAdvancedCards(false);
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
}
return (
<Dialog
open={newAgentOpen}
onOpenChange={(open) => {
if (!open) closeNewAgent();
if (!open) {
setShowAdvancedCards(false);
closeNewAgent();
}
}}
>
<DialogContent
@@ -56,13 +130,18 @@ export function NewAgentDialog() {
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={closeNewAgent}
onClick={() => {
setShowAdvancedCards(false);
closeNewAgent();
}}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
<div className="p-6 space-y-6">
{!showAdvancedCards ? (
<>
{/* Recommendation */}
<div className="text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
@@ -89,6 +168,46 @@ export function NewAgentDialog() {
I want advanced configuration myself
</button>
</div>
</>
) : (
<>
<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)}
>
<ArrowLeft className="h-3.5 w-3.5" />
Back
</button>
<p className="text-sm text-muted-foreground">
Choose your adapter type for advanced setup.
</p>
</div>
<div className="grid grid-cols-2 gap-2">
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
<button
key={opt.value}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
)}
onClick={() => handleAdvancedAdapterPick(opt.value)}
>
{opt.recommended && (
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">
Recommended
</span>
)}
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.desc}
</span>
</button>
))}
</div>
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -38,7 +38,6 @@ import {
ArrowLeft,
ArrowRight,
Terminal,
Globe,
Sparkles,
MousePointer2,
Check,
@@ -673,38 +672,19 @@ export function OnboardingWizard() {
icon: Terminal,
desc: "Local Pi agent"
},
{
value: "openclaw" as const,
label: "OpenClaw",
icon: Bot,
desc: "Notify OpenClaw webhook",
comingSoon: true
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol"
desc: "Invoke OpenClaw via gateway protocol",
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App"
},
{
value: "cursor" as const,
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "process" as const,
label: "Shell Command",
icon: Terminal,
desc: "Run a process",
comingSoon: true
},
{
value: "http" as const,
label: "HTTP Webhook",
icon: Globe,
desc: "Call an endpoint",
comingSoon: true
}
].map((opt) => (
<button
@@ -744,7 +724,10 @@ export function OnboardingWizard() {
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.comingSoon ? "Coming soon" : opt.desc}
{opt.comingSoon
? (opt as { disabledLabel?: string }).disabledLabel ??
"Coming soon"
: opt.desc}
</span>
</button>
))}

View File

@@ -77,9 +77,7 @@ export function CompanySettings() {
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createCompanyInvite(selectedCompanyId!, {
allowedJoinTypes: "agent"
}),
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
onSuccess: async (invite) => {
setInviteError(null);
const base = window.location.origin.replace(/\/+$/, "");
@@ -317,9 +315,9 @@ export function CompanySettings() {
<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 agent snippet for join flows.
Generate an openclaw agent invite snippet.
</span>
<HintIcon text="Creates an agent-only invite (10m) and renders a copy-ready snippet." />
<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
@@ -329,7 +327,7 @@ export function CompanySettings() {
>
{inviteMutation.isPending
? "Generating..."
: "Generate agent snippet"}
: "Generate OpenClaw Invite Prompt"}
</Button>
</div>
{inviteError && (
@@ -339,7 +337,7 @@ export function CompanySettings() {
<div className="rounded-md border border-border bg-muted/30 p-2">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
Agent Snippet
OpenClaw Invite Prompt
</div>
{snippetCopied && (
<span

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { agentsApi } from "../api/agents";
@@ -19,12 +19,45 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { AgentIcon } from "../components/AgentIconPicker";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"cursor",
"openclaw_gateway",
]);
function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"],
): CreateConfigValues {
const { adapterType: _discard, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType };
if (adapterType === "codex_local") {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (adapterType === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (adapterType === "opencode_local") {
nextValues.model = "";
}
return nextValues;
}
export function NewAgent() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const presetAdapterType = searchParams.get("adapterType");
const [name, setName] = useState("");
const [title, setTitle] = useState("");
@@ -71,6 +104,18 @@ export function NewAgent() {
}
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const requested = presetAdapterType;
if (!requested) return;
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
return;
}
setConfigValues((prev) => {
if (prev.adapterType === requested) return prev;
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
});
}, [presetAdapterType]);
const createAgent = useMutation({
mutationFn: (data: Record<string, unknown>) =>
agentsApi.hire(selectedCompanyId!, data),